codetrap 0.1.6 → 0.1.8

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 (60) hide show
  1. package/README.md +159 -51
  2. package/docs/installation.md +113 -29
  3. package/package.json +4 -3
  4. package/plugins/codetrap-agent/.codex-plugin/plugin.json +1 -2
  5. package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +19 -17
  6. package/plugins/codetrap-agent/hooks.json +2 -2
  7. package/{skills → plugins/codetrap-agent/skills}/codetrap-add/SKILL.md +10 -4
  8. package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +14 -3
  9. package/plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md +52 -9
  10. package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +74 -6
  11. package/{skills → plugins/codetrap-agent/skills}/codetrap-search/SKILL.md +6 -5
  12. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +31 -5
  13. package/scripts/search-policy-sweep.ts +131 -0
  14. package/src/commands/workflow.ts +186 -68
  15. package/src/db/connection.ts +6 -6
  16. package/src/db/embedding-queries.ts +230 -48
  17. package/src/db/queries.ts +0 -25
  18. package/src/db/repository.ts +32 -21
  19. package/src/db/schema.ts +80 -0
  20. package/src/index.ts +32 -7
  21. package/src/lib/command-requests.ts +134 -1
  22. package/src/lib/config.ts +57 -7
  23. package/src/lib/constants.ts +1 -1
  24. package/src/lib/doctor.ts +96 -6
  25. package/src/lib/embed-output.ts +26 -0
  26. package/src/lib/embedder.ts +118 -3
  27. package/src/lib/embedding-health.ts +3 -1
  28. package/src/lib/embedding-job.ts +3 -0
  29. package/src/lib/embedding-management.ts +65 -0
  30. package/src/lib/embedding-runtime.ts +177 -0
  31. package/src/lib/output-json.ts +0 -2
  32. package/src/lib/scope-context.ts +17 -11
  33. package/src/lib/scope-migration.ts +2 -1
  34. package/src/lib/scope.ts +4 -6
  35. package/src/lib/search-eval.ts +136 -23
  36. package/src/lib/search-policy-sweep.ts +563 -0
  37. package/src/lib/search-policy.ts +0 -4
  38. package/src/lib/search-service.ts +14 -15
  39. package/src/lib/session-candidate-document.ts +175 -0
  40. package/src/lib/session-candidate-scope.ts +6 -0
  41. package/src/lib/session-capture.ts +298 -32
  42. package/src/lib/session-codec.ts +1 -8
  43. package/src/lib/session-operations.ts +111 -51
  44. package/src/lib/session-review.ts +327 -0
  45. package/src/lib/session-store.ts +177 -55
  46. package/src/lib/store.ts +79 -11
  47. package/src/lib/string-list.ts +3 -0
  48. package/src/lib/text-lines.ts +7 -0
  49. package/src/lib/trap-search-document.ts +2 -1
  50. package/src/lib/value-types.ts +3 -0
  51. package/src/web/client-review.ts +171 -0
  52. package/src/web/client-script.ts +1543 -0
  53. package/src/web/client-shell.ts +414 -0
  54. package/src/web/client-text.ts +447 -0
  55. package/src/web/project-registry.ts +3 -5
  56. package/src/web/server.ts +184 -111
  57. package/src/web/static.ts +581 -484
  58. package/skills/codetrap-capture-external/SKILL.md +0 -62
  59. package/skills/codetrap-check/SKILL.md +0 -69
  60. package/src/lib/embedding-index.ts +0 -53
@@ -3,12 +3,22 @@ import {
3
3
  existsSync,
4
4
  mkdirSync,
5
5
  readFileSync,
6
+ rmSync,
6
7
  writeFileSync,
7
8
  } from "node:fs";
8
9
  import { join } from "node:path";
9
10
  import type { CandidateTrap, CandidateTrapDocument, SessionIndexDocument, SessionMetadata, SessionNote } from "../domain/session";
10
11
  import { parseSessionNoteKind, SESSION_VERSION } from "../domain/session";
11
12
  import { CODETRAP_DIR } from "./constants";
13
+ import {
14
+ acceptCandidateInDocument,
15
+ addCandidateToDocument,
16
+ recordCandidateConflictCheckInDocument,
17
+ rejectCandidateInDocument,
18
+ removeCandidatesFromDocument,
19
+ saveCandidateTrapInDocument,
20
+ type CandidateDocumentUpdateResult,
21
+ } from "./session-candidate-document";
12
22
  import {
13
23
  ACTIVE_SESSION_FILE,
14
24
  CANDIDATES_FILE,
@@ -22,13 +32,14 @@ import {
22
32
  recapSummary,
23
33
  RECAP_FILE,
24
34
  sessionIndexEntry,
35
+ sessionRelativeDir,
25
36
  SESSION_FILE,
26
37
  SESSION_INDEX_FILE,
27
38
  sessionRelativeFile,
28
39
  SESSIONS_DIR,
29
40
  } from "./session-codec";
30
- import { proposeCandidateTraps } from "./session-capture";
31
- import { scoreCandidateTrap } from "./trap-quality";
41
+ import { uniqueStrings } from "./string-list";
42
+ import { mergeCandidateTraps, proposeCandidateTraps, type CandidateDraft } from "./session-capture";
32
43
 
33
44
  export interface StartSessionArgs {
34
45
  goal: string;
@@ -48,7 +59,6 @@ export interface CloseSessionResult {
48
59
  session: SessionMetadata;
49
60
  recap_path: string;
50
61
  candidate_count: number;
51
- traps_written: number;
52
62
  }
53
63
 
54
64
  export interface AcceptCandidateResult {
@@ -60,6 +70,38 @@ export interface AcceptCandidateResult {
60
70
  superseded_id: number | null;
61
71
  }
62
72
 
73
+ export interface DeleteSessionResult {
74
+ session_id: string;
75
+ deleted: boolean;
76
+ active_cleared: boolean;
77
+ session_dir: string;
78
+ }
79
+
80
+ export interface PruneSessionsResult {
81
+ cutoff: string;
82
+ dry_run: boolean;
83
+ deleted_count: number;
84
+ sessions: {
85
+ id: string;
86
+ goal: string;
87
+ status: string;
88
+ created_at: string;
89
+ closed_at: string | null;
90
+ }[];
91
+ }
92
+
93
+ export interface RemoveSessionCandidatesResult {
94
+ session: SessionMetadata;
95
+ removed_count: number;
96
+ removed_candidate_ids: string[];
97
+ candidates: CandidateTrap[];
98
+ }
99
+
100
+ export interface AddSessionCandidateArgs {
101
+ sessionId: string;
102
+ draft: CandidateDraft;
103
+ }
104
+
63
105
  export class SessionStore {
64
106
  constructor(private readonly projectRoot: string) {}
65
107
 
@@ -190,7 +232,10 @@ export class SessionStore {
190
232
 
191
233
  const closedAt = now.toISOString();
192
234
  const notes = this.readNotes(session.id);
193
- const candidates = proposeTraps ? proposeCandidateTraps(session, notes) : this.readCandidateDocument(session.id).candidates;
235
+ const existingCandidates = this.readCandidateDocument(session.id).candidates;
236
+ const candidates = proposeTraps
237
+ ? mergeCandidateTraps(existingCandidates, proposeCandidateTraps(session, notes))
238
+ : existingCandidates;
194
239
  if (proposeTraps) this.writeCandidateDocument(session.id, candidates);
195
240
 
196
241
  const updated = {
@@ -208,7 +253,6 @@ export class SessionStore {
208
253
  session: updated,
209
254
  recap_path: sessionRelativeFile(updated.id, RECAP_FILE),
210
255
  candidate_count: candidates.length,
211
- traps_written: 0,
212
256
  };
213
257
  }
214
258
 
@@ -224,6 +268,22 @@ export class SessionStore {
224
268
  return { session, candidate };
225
269
  }
226
270
 
271
+ addCandidate(args: AddSessionCandidateArgs): { session: SessionMetadata; candidate: CandidateTrap; candidates_path: string; duplicate: boolean } {
272
+ const session = this.requireSession(args.sessionId);
273
+ const document = this.readCandidateDocument(session.id);
274
+ const added = addCandidateToDocument(document.candidates, args.draft);
275
+ if (!added.duplicate) {
276
+ this.writeCandidateDocument(session.id, added.candidates);
277
+ this.refreshSessionSummaries(session.id);
278
+ }
279
+ return {
280
+ session: this.requireSession(session.id),
281
+ candidate: added.candidate,
282
+ candidates_path: sessionRelativeFile(session.id, CANDIDATES_FILE),
283
+ duplicate: added.duplicate,
284
+ };
285
+ }
286
+
227
287
  recordCandidateConflictCheck(
228
288
  candidateId: string,
229
289
  args: {
@@ -236,17 +296,16 @@ export class SessionStore {
236
296
  const sessionId = this.resolveSessionId(args.sessionId);
237
297
  const session = this.requireSession(sessionId);
238
298
  const document = this.readCandidateDocument(sessionId);
239
- const candidate = document.candidates.find((item) => item.id === candidateId);
240
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
241
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
242
-
243
- if (args.trap) candidate.trap = args.trap;
244
- candidate.quality.conflict_checked = true;
245
- candidate.quality.conflict_status = args.conflictStatus;
246
- candidate.quality.suggested_action = args.suggestedAction;
247
- this.writeCandidateDocument(session.id, document.candidates);
248
- this.refreshSessionSummaries(session.id);
249
- return { session, candidate };
299
+ return this.saveCandidateDocumentMutation(session, recordCandidateConflictCheckInDocument(
300
+ document.candidates,
301
+ candidateId,
302
+ {
303
+ sessionId,
304
+ trap: args.trap,
305
+ conflictStatus: args.conflictStatus,
306
+ suggestedAction: args.suggestedAction,
307
+ }
308
+ ));
250
309
  }
251
310
 
252
311
  saveCandidateTrap(
@@ -259,17 +318,11 @@ export class SessionStore {
259
318
  const sessionId = this.resolveSessionId(args.sessionId);
260
319
  const session = this.requireSession(sessionId);
261
320
  const document = this.readCandidateDocument(sessionId);
262
- const candidate = document.candidates.find((item) => item.id === candidateId);
263
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
264
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
265
-
266
- const scored = scoreCandidateTrap({ trap: args.trap, evidence: candidate.evidence });
267
- candidate.trap = args.trap;
268
- candidate.quality_score = scored.score;
269
- candidate.quality = scored.quality;
270
- this.writeCandidateDocument(session.id, document.candidates);
271
- this.refreshSessionSummaries(session.id);
272
- return { session, candidate };
321
+ return this.saveCandidateDocumentMutation(session, saveCandidateTrapInDocument(
322
+ document.candidates,
323
+ candidateId,
324
+ { sessionId, trap: args.trap }
325
+ ));
273
326
  }
274
327
 
275
328
  acceptCandidate(
@@ -290,24 +343,24 @@ export class SessionStore {
290
343
  const sessionId = this.resolveSessionId(args.sessionId);
291
344
  const session = this.requireSession(sessionId);
292
345
  const document = this.readCandidateDocument(sessionId);
293
- const candidate = document.candidates.find((item) => item.id === candidateId);
294
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
295
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
296
-
297
- if (args.trap) candidate.trap = args.trap;
298
- candidate.status = "accepted";
299
- candidate.accepted_trap_id = args.trapId;
300
- candidate.accepted_scope = args.scope === "project" ? "project" : "global";
301
- candidate.accepted_at = now.toISOString();
302
- candidate.quality.conflict_checked = args.conflictChecked ?? candidate.quality.conflict_checked;
303
- candidate.quality.conflict_status = args.conflictStatus ?? candidate.quality.conflict_status;
304
- candidate.quality.suggested_action = args.suggestedAction ?? candidate.quality.suggested_action;
305
- this.writeCandidateDocument(session.id, document.candidates);
306
- this.refreshSessionSummaries(session.id);
346
+ const accepted = this.saveCandidateDocumentMutation(session, acceptCandidateInDocument(
347
+ document.candidates,
348
+ candidateId,
349
+ {
350
+ sessionId,
351
+ trap: args.trap,
352
+ trapId: args.trapId,
353
+ scope: args.scope,
354
+ conflictChecked: args.conflictChecked,
355
+ conflictStatus: args.conflictStatus,
356
+ suggestedAction: args.suggestedAction,
357
+ },
358
+ now
359
+ ));
307
360
 
308
361
  return {
309
362
  session,
310
- candidate,
363
+ candidate: accepted.candidate,
311
364
  trap_id: args.trapId,
312
365
  scope: args.scope,
313
366
  evidence_id: args.evidenceId,
@@ -323,16 +376,73 @@ export class SessionStore {
323
376
  const sessionId = this.resolveSessionId(args.sessionId);
324
377
  const session = this.requireSession(sessionId);
325
378
  const document = this.readCandidateDocument(sessionId);
326
- const candidate = document.candidates.find((item) => item.id === candidateId);
327
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
328
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
379
+ return this.saveCandidateDocumentMutation(session, rejectCandidateInDocument(
380
+ document.candidates,
381
+ candidateId,
382
+ { sessionId, reason: args.reason },
383
+ now
384
+ ));
385
+ }
329
386
 
330
- candidate.status = "rejected";
331
- candidate.rejected_at = now.toISOString();
332
- if (args.reason) candidate.rejection_reason = args.reason;
333
- this.writeCandidateDocument(session.id, document.candidates);
334
- this.refreshSessionSummaries(session.id);
335
- return { session, candidate };
387
+ removeCandidates(sessionId: string | undefined, candidateIds: string[]): RemoveSessionCandidatesResult {
388
+ const resolvedSessionId = this.resolveSessionId(sessionId);
389
+ const session = this.requireSession(resolvedSessionId);
390
+ const document = this.readCandidateDocument(session.id);
391
+ const removed = removeCandidatesFromDocument(document.candidates, candidateIds);
392
+ if (removed.removed.length > 0) {
393
+ this.writeCandidateDocument(session.id, removed.candidates);
394
+ this.refreshSessionSummaries(session.id);
395
+ }
396
+
397
+ return {
398
+ session,
399
+ removed_count: removed.removed.length,
400
+ removed_candidate_ids: removed.removed.map((candidate) => candidate.id),
401
+ candidates: removed.candidates,
402
+ };
403
+ }
404
+
405
+ deleteSession(id: string): DeleteSessionResult {
406
+ this.requireSession(id);
407
+ const active_cleared = this.readActive()?.active_session_id === id;
408
+ rmSync(this.sessionDir(id), { recursive: true, force: true });
409
+ this.removeIndexEntry(id);
410
+ if (active_cleared) this.clearActive();
411
+ return {
412
+ session_id: id,
413
+ deleted: true,
414
+ active_cleared,
415
+ session_dir: sessionRelativeDir(id),
416
+ };
417
+ }
418
+
419
+ pruneSessions(args: { cutoff: Date; dryRun?: boolean }): PruneSessionsResult {
420
+ const cutoffTime = args.cutoff.getTime();
421
+ const candidates = this.readIndex().sessions
422
+ .filter((entry) => entry.status === "closed")
423
+ .filter((entry) => {
424
+ const closedAt = entry.closed_at ? Date.parse(entry.closed_at) : Date.parse(entry.created_at);
425
+ return Number.isFinite(closedAt) && closedAt < cutoffTime;
426
+ });
427
+
428
+ if (!args.dryRun) {
429
+ for (const session of candidates) {
430
+ this.deleteSession(session.id);
431
+ }
432
+ }
433
+
434
+ return {
435
+ cutoff: args.cutoff.toISOString(),
436
+ dry_run: args.dryRun ?? false,
437
+ deleted_count: args.dryRun ? 0 : candidates.length,
438
+ sessions: candidates.map((session) => ({
439
+ id: session.id,
440
+ goal: session.goal,
441
+ status: session.status,
442
+ created_at: session.created_at,
443
+ closed_at: session.closed_at,
444
+ })),
445
+ };
336
446
  }
337
447
 
338
448
  readNotes(id: string): SessionNote[] {
@@ -387,6 +497,15 @@ export class SessionStore {
387
497
  return candidate;
388
498
  }
389
499
 
500
+ private saveCandidateDocumentMutation(
501
+ session: SessionMetadata,
502
+ mutation: CandidateDocumentUpdateResult
503
+ ): { session: SessionMetadata; candidate: CandidateTrap } {
504
+ this.writeCandidateDocument(session.id, mutation.candidates);
505
+ this.refreshSessionSummaries(session.id);
506
+ return { session, candidate: mutation.candidate };
507
+ }
508
+
390
509
  private readCandidateDocument(id: string): CandidateTrapDocument {
391
510
  const path = this.candidatesPath(id);
392
511
  if (!existsSync(path)) {
@@ -436,6 +555,13 @@ export class SessionStore {
436
555
  writeFileSync(this.indexPath(), `${JSON.stringify({ version: SESSION_VERSION, sessions } satisfies SessionIndexDocument, null, 2)}\n`);
437
556
  }
438
557
 
558
+ private removeIndexEntry(id: string): void {
559
+ this.ensureSessionsDir();
560
+ const index = this.readIndex();
561
+ const sessions = index.sessions.filter((entry) => entry.id !== id);
562
+ writeFileSync(this.indexPath(), `${JSON.stringify({ version: SESSION_VERSION, sessions } satisfies SessionIndexDocument, null, 2)}\n`);
563
+ }
564
+
439
565
  private readActive(): { active_session_id: string | null; updated_at: string } | null {
440
566
  const path = this.activePath();
441
567
  if (!existsSync(path)) return null;
@@ -497,7 +623,3 @@ export class SessionStore {
497
623
  return join(this.sessionDir(id), CANDIDATES_FILE);
498
624
  }
499
625
  }
500
-
501
- function uniqueStrings(values: string[]): string[] {
502
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
503
- }
package/src/lib/store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EmbeddingProfileSummary } from "../db/embedding-queries";
1
2
  import type { TrapStats } from "../db/repository";
2
3
  import {
3
4
  type Trap,
@@ -10,7 +11,20 @@ import {
10
11
  type TrapUpdate,
11
12
  } from "../domain/trap";
12
13
  import type { SearchMode, TrapStatus } from "./constants";
13
- import { createDefaultEmbeddingProvider, embeddingConfig, type EmbeddingConfig, type EmbeddingProvider } from "./embedder";
14
+ import type { EmbeddingConfig } from "./embedder";
15
+ import {
16
+ defaultEmbeddingRuntime,
17
+ embeddingRuntimeFrom,
18
+ type EmbeddingRuntime,
19
+ type EmbeddingRuntimeInput,
20
+ type EmbeddingRuntimeStatus,
21
+ } from "./embedding-runtime";
22
+ import {
23
+ type ConfigWriteResult,
24
+ type EmbeddingSettings,
25
+ loadCodetrapConfig,
26
+ setCodetrapEmbeddingSettings,
27
+ } from "./config";
14
28
  import { summarizeEmbeddingState, type EmbeddingStateSummary, type EmbeddingStatsResult } from "./embedding-health";
15
29
  import { normalizeScope, ScopedRepositoryContext, type ScopedRepository } from "./scope-context";
16
30
  import { importTrapArchive } from "./trap-archive";
@@ -33,14 +47,32 @@ export {
33
47
 
34
48
  export type { EmbeddingStateSummary };
35
49
  export type TrapEmbeddingStats = EmbeddingStatsResult;
50
+ export type TrapEmbeddingProfiles = {
51
+ project: EmbeddingProfileSummary[] | null;
52
+ global: EmbeddingProfileSummary[] | null;
53
+ };
54
+ export type EmbeddingScopeStatus = EmbeddingStateSummary & {
55
+ profiles: EmbeddingProfileSummary[];
56
+ };
57
+ export type TrapEmbeddingStatus = {
58
+ runtime: EmbeddingRuntimeStatus;
59
+ project: EmbeddingScopeStatus | null;
60
+ global: EmbeddingScopeStatus | null;
61
+ };
36
62
 
37
63
  export class TrapStore {
38
64
  private readonly scopes: ScopedRepositoryContext;
39
- private readonly embedder?: EmbeddingProvider;
40
-
41
- constructor(cwd: string, embedder: EmbeddingProvider | undefined = createDefaultEmbeddingProvider()) {
42
- this.embedder = embedder;
43
- this.scopes = new ScopedRepositoryContext(cwd, embedder);
65
+ private readonly embeddings: EmbeddingRuntime;
66
+
67
+ constructor(
68
+ cwd: string,
69
+ embeddings?: EmbeddingRuntimeInput,
70
+ private readonly home?: string
71
+ ) {
72
+ this.embeddings = embeddings === undefined
73
+ ? defaultEmbeddingRuntime(process.env, loadCodetrapConfig(home))
74
+ : embeddingRuntimeFrom(embeddings);
75
+ this.scopes = new ScopedRepositoryContext(cwd, this.embeddings, home);
44
76
  }
45
77
 
46
78
  add(input: TrapInput): { id: number; scope: string } {
@@ -195,6 +227,34 @@ export class TrapStore {
195
227
  };
196
228
  }
197
229
 
230
+ embeddingProfiles(opts: { scope?: string } = {}): TrapEmbeddingProfiles {
231
+ const scope = opts.scope ? normalizeScope(opts.scope) : null;
232
+ const project = scope === "global"
233
+ ? null
234
+ : this.scopes.repositoryEntry("project")
235
+ ? this.scopes.repositoryFor("project").embeddingProfiles({ scope: "project" })
236
+ : null;
237
+ const global = scope === "project"
238
+ ? null
239
+ : this.scopes.repositoryFor("global").embeddingProfiles({ scope: "global" });
240
+ return { project, global };
241
+ }
242
+
243
+ async embeddingStatus(opts: { scope?: string } = {}): Promise<TrapEmbeddingStatus> {
244
+ const runtime = await this.embeddingRuntimeHealth();
245
+ const stats = this.embeddingStats(opts);
246
+ const profiles = this.embeddingProfiles(opts);
247
+ return {
248
+ runtime,
249
+ project: stats.project
250
+ ? { ...stats.project, profiles: profiles.project ?? [] }
251
+ : null,
252
+ global: stats.global
253
+ ? { ...stats.global, profiles: profiles.global ?? [] }
254
+ : null,
255
+ };
256
+ }
257
+
198
258
  diagnostics(): {
199
259
  mis_scoped_traps: {
200
260
  global_db_project_traps: Pick<Trap, "id" | "title" | "scope" | "project_path" | "status">[];
@@ -238,16 +298,24 @@ export class TrapStore {
238
298
  return this.scopes.projectRoot();
239
299
  }
240
300
 
241
- hasEmbeddingProvider(): boolean {
242
- return this.embedder !== undefined;
301
+ embeddingConfig(): EmbeddingConfig | null {
302
+ return this.embeddings.config();
243
303
  }
244
304
 
245
- embeddingConfig(): EmbeddingConfig | null {
246
- return this.embedder ? embeddingConfig(this.embedder) : null;
305
+ embeddingRuntimeStatus(): EmbeddingRuntimeStatus {
306
+ return this.embeddings.status();
307
+ }
308
+
309
+ embeddingRuntimeHealth(): Promise<EmbeddingRuntimeStatus> {
310
+ return this.embeddings.health();
311
+ }
312
+
313
+ configureEmbeddings(settings: EmbeddingSettings): ConfigWriteResult {
314
+ return setCodetrapEmbeddingSettings(settings, this.home);
247
315
  }
248
316
 
249
317
  forCwd(cwd: string): TrapStore {
250
- return new TrapStore(cwd, this.embedder);
318
+ return new TrapStore(cwd, this.embeddings, this.home);
251
319
  }
252
320
 
253
321
  async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
@@ -0,0 +1,3 @@
1
+ export function uniqueStrings(values: string[]): string[] {
2
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
3
+ }
@@ -0,0 +1,7 @@
1
+ export function trimOuterBlankLines(lines: string[]): string[] {
2
+ let start = 0;
3
+ let end = lines.length;
4
+ while (start < end && lines[start].trim() === "") start++;
5
+ while (end > start && lines[end - 1].trim() === "") end--;
6
+ return lines.slice(start, end);
7
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Trap, TrapInput, TrapUpdate } from "../domain/trap";
3
- import type { EmbeddingConfig, StoredEmbedding } from "./embedder";
3
+ import { embeddingProfileId, type EmbeddingConfig, type StoredEmbedding } from "./embedder";
4
4
  import { buildSearchText, SEARCH_TEXT_FIELD_NAMES, type SearchTextFields } from "./search-normalizer";
5
5
  import { parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
6
6
 
@@ -74,6 +74,7 @@ export function embeddingIsFresh(trap: Trap, embedding: StoredEmbedding | null,
74
74
  embedding.provider === config.provider &&
75
75
  embedding.model === config.model &&
76
76
  embedding.dimensions === config.dimensions &&
77
+ embedding.profile_id === embeddingProfileId(config) &&
77
78
  embedding.passage_version === config.passageVersion &&
78
79
  embedding.passage_hash === passageHashForTrap(trap)
79
80
  );
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
@@ -0,0 +1,171 @@
1
+ export type ReviewSessionLike = {
2
+ id: string;
3
+ pending_count?: number | null;
4
+ };
5
+
6
+ export type ReviewCandidateView = "inbox" | "reviewed";
7
+
8
+ export type ReviewCandidateLike = {
9
+ id: string;
10
+ status: string;
11
+ quality_score: number;
12
+ };
13
+
14
+ export type ReviewSummaryLike = {
15
+ pending_count?: number | null;
16
+ pending_session_count?: number | null;
17
+ high_quality_pending_count?: number | null;
18
+ needs_edit_count?: number | null;
19
+ };
20
+
21
+ export type ReviewCandidateDraftFields = Record<string, unknown>;
22
+
23
+ export type ReviewCandidateTrapDraft = {
24
+ title: string;
25
+ category: string;
26
+ scope: string;
27
+ severity: string;
28
+ tags: string[];
29
+ path_globs: string[];
30
+ module: string | null;
31
+ owner: string | null;
32
+ context: string;
33
+ mistake: string;
34
+ fix: string;
35
+ };
36
+
37
+ export type ReviewCandidateMutationPayloadArgs = {
38
+ projectRoot: string | null;
39
+ sessionId: string | null;
40
+ candidateId: string | null;
41
+ trap: ReviewCandidateTrapDraft;
42
+ extra?: Record<string, unknown>;
43
+ };
44
+
45
+ export type ReviewCandidateMutationPayload = {
46
+ projectRoot: string | null;
47
+ sessionId: string | null;
48
+ candidateId: string | null;
49
+ trap: ReviewCandidateTrapDraft;
50
+ } & Record<string, unknown>;
51
+
52
+ export type ReviewQueueModelArgs<TCandidate extends ReviewCandidateLike> = {
53
+ candidates: TCandidate[];
54
+ candidateView: ReviewCandidateView;
55
+ candidateId?: string | null;
56
+ candidateReview?: ReviewSummaryLike | null;
57
+ };
58
+
59
+ export function selectedReviewSessionId(
60
+ sessions: ReviewSessionLike[],
61
+ currentSessionId: string | null | undefined
62
+ ): string | null {
63
+ if (currentSessionId && sessions.some((session) => session.id === currentSessionId)) {
64
+ return currentSessionId;
65
+ }
66
+ return sessions.find((session) => (session.pending_count || 0) > 0)?.id || sessions[0]?.id || null;
67
+ }
68
+
69
+ export function reviewQueueModel<TCandidate extends ReviewCandidateLike>(
70
+ args: ReviewQueueModelArgs<TCandidate>
71
+ ) {
72
+ const pendingCount = args.candidates.filter((candidate) => candidate.status === "proposed").length;
73
+ const reviewedCount = args.candidates.length - pendingCount;
74
+ const visibleCandidates = sortedReviewCandidates(args.candidates, args.candidateView);
75
+ return {
76
+ pendingCount,
77
+ reviewedCount,
78
+ visibleCandidates,
79
+ selectedCandidateId: selectedReviewCandidateId(visibleCandidates, args.candidateId),
80
+ summary: visibleReviewSummary(args.candidateReview),
81
+ };
82
+ }
83
+
84
+ export function sortedReviewCandidates<TCandidate extends ReviewCandidateLike>(
85
+ candidates: TCandidate[],
86
+ candidateView: ReviewCandidateView
87
+ ): TCandidate[] {
88
+ return [...candidates]
89
+ .filter((candidate) => candidateVisibleInReviewView(candidate, candidateView))
90
+ .sort((a, b) => reviewStatusRank(a.status) - reviewStatusRank(b.status) || b.quality_score - a.quality_score);
91
+ }
92
+
93
+ export function selectedReviewCandidateId(
94
+ candidates: ReviewCandidateLike[],
95
+ currentCandidateId: string | null | undefined
96
+ ): string | null {
97
+ if (currentCandidateId && candidates.some((candidate) => candidate.id === currentCandidateId)) {
98
+ return currentCandidateId;
99
+ }
100
+ return candidates[0]?.id || null;
101
+ }
102
+
103
+ export function visibleReviewSummary(summary: ReviewSummaryLike | null | undefined): ReviewSummaryLike | null {
104
+ if (!summary || (summary.pending_count || 0) === 0) return null;
105
+ return summary;
106
+ }
107
+
108
+ export function candidateVisibleInReviewView(
109
+ candidate: ReviewCandidateLike,
110
+ candidateView: ReviewCandidateView
111
+ ): boolean {
112
+ return candidateView === "inbox" ? candidate.status === "proposed" : candidate.status !== "proposed";
113
+ }
114
+
115
+ export function reviewStatusRank(status: string): number {
116
+ return status === "proposed" ? 0 : status === "accepted" ? 1 : 2;
117
+ }
118
+
119
+ export function reviewCandidateTrapDraft(fields: ReviewCandidateDraftFields): ReviewCandidateTrapDraft {
120
+ return {
121
+ title: String(fields.title || ""),
122
+ category: String(fields.category || ""),
123
+ scope: String(fields.scope || ""),
124
+ severity: String(fields.severity || ""),
125
+ tags: reviewSplitList(fields.tags),
126
+ path_globs: reviewSplitList(fields.path_globs),
127
+ module: reviewBlankToNull(fields.module),
128
+ owner: reviewBlankToNull(fields.owner),
129
+ context: String(fields.context || ""),
130
+ mistake: String(fields.mistake || ""),
131
+ fix: String(fields.fix || ""),
132
+ };
133
+ }
134
+
135
+ export function reviewCandidateMutationPayload(args: ReviewCandidateMutationPayloadArgs): ReviewCandidateMutationPayload {
136
+ return {
137
+ projectRoot: args.projectRoot,
138
+ sessionId: args.sessionId,
139
+ candidateId: args.candidateId,
140
+ trap: args.trap,
141
+ ...(args.extra || {}),
142
+ };
143
+ }
144
+
145
+ function reviewSplitList(value: unknown): string[] {
146
+ const values = Array.isArray(value) ? value : String(value || "").split(",");
147
+ return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
148
+ }
149
+
150
+ function reviewBlankToNull(value: unknown): string | null {
151
+ const text = String(value || "").trim();
152
+ return text ? text : null;
153
+ }
154
+
155
+ const CLIENT_REVIEW_FUNCTIONS = [
156
+ selectedReviewSessionId,
157
+ reviewQueueModel,
158
+ sortedReviewCandidates,
159
+ selectedReviewCandidateId,
160
+ visibleReviewSummary,
161
+ candidateVisibleInReviewView,
162
+ reviewStatusRank,
163
+ reviewCandidateTrapDraft,
164
+ reviewCandidateMutationPayload,
165
+ reviewSplitList,
166
+ reviewBlankToNull,
167
+ ];
168
+
169
+ export const WEB_REVIEW_CLIENT_SCRIPT = CLIENT_REVIEW_FUNCTIONS
170
+ .map((fn) => ` ${fn.toString().replace(/\n/g, "\n ")}`)
171
+ .join("\n\n");