codetrap 0.1.5 → 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.
@@ -0,0 +1,611 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import type { CandidateTrap, CandidateTrapDocument, SessionIndexDocument, SessionMetadata, SessionNote } from "../domain/session";
11
+ import { parseSessionNoteKind, SESSION_VERSION } from "../domain/session";
12
+ import { CODETRAP_DIR } from "./constants";
13
+ import {
14
+ ACTIVE_SESSION_FILE,
15
+ CANDIDATES_FILE,
16
+ createSessionId,
17
+ formatImplementationNotesHeader,
18
+ formatRecap,
19
+ formatSessionNote,
20
+ NOTES_FILE,
21
+ noteCounts,
22
+ parseSessionNotes,
23
+ recapSummary,
24
+ RECAP_FILE,
25
+ sessionIndexEntry,
26
+ sessionRelativeDir,
27
+ SESSION_FILE,
28
+ SESSION_INDEX_FILE,
29
+ sessionRelativeFile,
30
+ SESSIONS_DIR,
31
+ } from "./session-codec";
32
+ import { proposeCandidateTraps } from "./session-capture";
33
+ import { scoreCandidateTrap } from "./trap-quality";
34
+
35
+ export interface StartSessionArgs {
36
+ goal: string;
37
+ specRef?: string | null;
38
+ module?: string | null;
39
+ owner?: string | null;
40
+ }
41
+
42
+ export interface AddSessionNoteArgs {
43
+ kind?: string;
44
+ text: string;
45
+ relatedFiles?: string[];
46
+ sourceRef?: string | null;
47
+ }
48
+
49
+ export interface CloseSessionResult {
50
+ session: SessionMetadata;
51
+ recap_path: string;
52
+ candidate_count: number;
53
+ traps_written: number;
54
+ }
55
+
56
+ export interface AcceptCandidateResult {
57
+ session: SessionMetadata;
58
+ candidate: CandidateTrap;
59
+ trap_id: number;
60
+ scope: string;
61
+ evidence_id: number | null;
62
+ superseded_id: number | null;
63
+ }
64
+
65
+ export interface DeleteSessionResult {
66
+ session_id: string;
67
+ deleted: boolean;
68
+ active_cleared: boolean;
69
+ session_dir: string;
70
+ }
71
+
72
+ export interface PruneSessionsResult {
73
+ cutoff: string;
74
+ dry_run: boolean;
75
+ deleted_count: number;
76
+ sessions: {
77
+ id: string;
78
+ goal: string;
79
+ status: string;
80
+ created_at: string;
81
+ closed_at: string | null;
82
+ }[];
83
+ }
84
+
85
+ export interface RemoveSessionCandidatesResult {
86
+ session: SessionMetadata;
87
+ removed_count: number;
88
+ removed_candidate_ids: string[];
89
+ candidates: CandidateTrap[];
90
+ }
91
+
92
+ export class SessionStore {
93
+ constructor(private readonly projectRoot: string) {}
94
+
95
+ startSession(args: StartSessionArgs, now = new Date()): SessionMetadata {
96
+ const active = this.activeSession();
97
+ if (active) {
98
+ throw new Error(`Session ${active.id} is already active. Close it before starting another session.`);
99
+ }
100
+
101
+ const goal = args.goal.trim();
102
+ if (!goal) throw new Error("Session goal is required.");
103
+
104
+ this.ensureSessionsDir();
105
+ const id = createSessionId(goal, now, (candidate) => existsSync(this.sessionDir(candidate)));
106
+ const createdAt = now.toISOString();
107
+ const session: SessionMetadata = {
108
+ version: SESSION_VERSION,
109
+ id,
110
+ goal,
111
+ status: "active",
112
+ created_at: createdAt,
113
+ updated_at: createdAt,
114
+ closed_at: null,
115
+ scope: "project",
116
+ project_path: this.projectRoot,
117
+ module: args.module ?? null,
118
+ owner: args.owner ?? null,
119
+ spec_ref: args.specRef ?? null,
120
+ notes_path: NOTES_FILE,
121
+ recap_path: RECAP_FILE,
122
+ candidate_traps_path: CANDIDATES_FILE,
123
+ };
124
+
125
+ mkdirSync(this.sessionDir(id), { recursive: true });
126
+ this.writeSession(session);
127
+ writeFileSync(this.notesPath(id), formatImplementationNotesHeader(session));
128
+ this.writeIndexEntry(session, [], [], null);
129
+ this.writeActive(id);
130
+ return session;
131
+ }
132
+
133
+ addNote(args: AddSessionNoteArgs, now = new Date()): { session: SessionMetadata; note: SessionNote; notes_path: string } {
134
+ const session = this.requireActiveSession();
135
+ const text = args.text.trim();
136
+ if (!text) throw new Error("Session note text is required.");
137
+
138
+ const note: SessionNote = {
139
+ created_at: now.toISOString(),
140
+ kind: parseSessionNoteKind(args.kind),
141
+ text,
142
+ related_files: uniqueStrings(args.relatedFiles ?? []),
143
+ source_ref: args.sourceRef ?? null,
144
+ };
145
+
146
+ appendFileSync(this.notesPath(session.id), formatSessionNote(note));
147
+ const updated = { ...session, updated_at: note.created_at };
148
+ this.writeSession(updated);
149
+ this.writeIndexEntry(updated, this.readNotes(updated.id), this.readCandidateDocument(updated.id).candidates, null);
150
+ return {
151
+ session: updated,
152
+ note,
153
+ notes_path: sessionRelativeFile(updated.id, NOTES_FILE),
154
+ };
155
+ }
156
+
157
+ status(): { active_session_id: string | null; session: SessionMetadata | null } {
158
+ const active = this.activeSession();
159
+ return {
160
+ active_session_id: active?.id ?? null,
161
+ session: active,
162
+ };
163
+ }
164
+
165
+ listSessions(args: { status?: string; limit?: number } = {}) {
166
+ const status = args.status ?? "all";
167
+ if (!["active", "closed", "all"].includes(status)) {
168
+ throw new Error("Invalid session status. Expected active, closed, or all.");
169
+ }
170
+ const sessions = this.readIndex().sessions
171
+ .filter((entry) => status === "all" || entry.status === status)
172
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
173
+ return typeof args.limit === "number" ? sessions.slice(0, args.limit) : sessions;
174
+ }
175
+
176
+ getSession(id: string): SessionMetadata {
177
+ return this.requireSession(id);
178
+ }
179
+
180
+ showSession(id: string): { session: SessionMetadata; recap: string | null; notes_path: string; session_dir: string } {
181
+ const session = this.requireSession(id);
182
+ return {
183
+ session,
184
+ recap: this.readOptionalText(this.recapPath(id)),
185
+ notes_path: sessionRelativeFile(id, NOTES_FILE),
186
+ session_dir: join(".codetrap", SESSIONS_DIR, id),
187
+ };
188
+ }
189
+
190
+ summarizeNotes(id?: string): {
191
+ session: SessionMetadata;
192
+ notes_path: string;
193
+ content: string;
194
+ notes: SessionNote[];
195
+ note_counts: ReturnType<typeof noteCounts>;
196
+ latest_notes: { created_at: string; kind: string; text: string; related_files: string[] }[];
197
+ } {
198
+ const session = this.requireSession(this.resolveSessionId(id));
199
+ const content = this.readOptionalText(this.notesPath(session.id)) ?? "";
200
+ const notes = parseSessionNotes(content);
201
+ return {
202
+ session,
203
+ notes_path: sessionRelativeFile(session.id, NOTES_FILE),
204
+ content,
205
+ notes,
206
+ note_counts: noteCounts(notes),
207
+ latest_notes: notes.slice(-3).reverse().map((note) => ({
208
+ created_at: note.created_at,
209
+ kind: note.kind,
210
+ text: note.text,
211
+ related_files: note.related_files,
212
+ })),
213
+ };
214
+ }
215
+
216
+ closeSession(id: string | undefined, proposeTraps: boolean, now = new Date()): CloseSessionResult {
217
+ const session = this.requireSession(this.resolveSessionId(id, { requireActive: id === undefined }));
218
+ if (session.status === "closed") throw new Error(`Session ${session.id} is already closed.`);
219
+
220
+ const closedAt = now.toISOString();
221
+ const notes = this.readNotes(session.id);
222
+ const candidates = proposeTraps ? proposeCandidateTraps(session, notes) : this.readCandidateDocument(session.id).candidates;
223
+ if (proposeTraps) this.writeCandidateDocument(session.id, candidates);
224
+
225
+ const updated = {
226
+ ...session,
227
+ status: "closed" as const,
228
+ closed_at: closedAt,
229
+ updated_at: closedAt,
230
+ };
231
+ this.writeSession(updated);
232
+ writeFileSync(this.recapPath(updated.id), formatRecap(updated, notes, candidates));
233
+ this.writeIndexEntry(updated, notes, candidates, recapSummary(updated, notes, candidates));
234
+ if (this.readActive()?.active_session_id === updated.id) this.clearActive();
235
+
236
+ return {
237
+ session: updated,
238
+ recap_path: sessionRelativeFile(updated.id, RECAP_FILE),
239
+ candidate_count: candidates.length,
240
+ traps_written: 0,
241
+ };
242
+ }
243
+
244
+ candidateDocument(id?: string): CandidateTrapDocument {
245
+ const sessionId = this.resolveSessionId(id);
246
+ return this.readCandidateDocument(sessionId);
247
+ }
248
+
249
+ getCandidate(candidateId: string, sessionId?: string): { session: SessionMetadata; candidate: CandidateTrap } {
250
+ const resolvedSessionId = this.resolveSessionId(sessionId);
251
+ const session = this.requireSession(resolvedSessionId);
252
+ const candidate = this.findCandidate(resolvedSessionId, candidateId);
253
+ return { session, candidate };
254
+ }
255
+
256
+ recordCandidateConflictCheck(
257
+ candidateId: string,
258
+ args: {
259
+ sessionId?: string;
260
+ trap?: CandidateTrap["trap"];
261
+ conflictStatus: CandidateTrap["quality"]["conflict_status"];
262
+ suggestedAction: CandidateTrap["quality"]["suggested_action"];
263
+ }
264
+ ): { session: SessionMetadata; candidate: CandidateTrap } {
265
+ const sessionId = this.resolveSessionId(args.sessionId);
266
+ const session = this.requireSession(sessionId);
267
+ const document = this.readCandidateDocument(sessionId);
268
+ const candidate = document.candidates.find((item) => item.id === candidateId);
269
+ if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
270
+ if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
271
+
272
+ if (args.trap) candidate.trap = args.trap;
273
+ candidate.quality.conflict_checked = true;
274
+ candidate.quality.conflict_status = args.conflictStatus;
275
+ candidate.quality.suggested_action = args.suggestedAction;
276
+ this.writeCandidateDocument(session.id, document.candidates);
277
+ this.refreshSessionSummaries(session.id);
278
+ return { session, candidate };
279
+ }
280
+
281
+ saveCandidateTrap(
282
+ candidateId: string,
283
+ args: {
284
+ sessionId?: string;
285
+ trap: CandidateTrap["trap"];
286
+ }
287
+ ): { session: SessionMetadata; candidate: CandidateTrap } {
288
+ const sessionId = this.resolveSessionId(args.sessionId);
289
+ const session = this.requireSession(sessionId);
290
+ const document = this.readCandidateDocument(sessionId);
291
+ const candidate = document.candidates.find((item) => item.id === candidateId);
292
+ if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
293
+ if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
294
+
295
+ const scored = scoreCandidateTrap({ trap: args.trap, evidence: candidate.evidence });
296
+ candidate.trap = args.trap;
297
+ candidate.quality_score = scored.score;
298
+ candidate.quality = scored.quality;
299
+ this.writeCandidateDocument(session.id, document.candidates);
300
+ this.refreshSessionSummaries(session.id);
301
+ return { session, candidate };
302
+ }
303
+
304
+ acceptCandidate(
305
+ candidateId: string,
306
+ args: {
307
+ sessionId?: string;
308
+ trap?: CandidateTrap["trap"];
309
+ trapId: number;
310
+ scope: string;
311
+ evidenceId: number | null;
312
+ supersededId?: number | null;
313
+ conflictChecked?: boolean;
314
+ conflictStatus?: CandidateTrap["quality"]["conflict_status"];
315
+ suggestedAction?: CandidateTrap["quality"]["suggested_action"];
316
+ },
317
+ now = new Date()
318
+ ): AcceptCandidateResult {
319
+ const sessionId = this.resolveSessionId(args.sessionId);
320
+ const session = this.requireSession(sessionId);
321
+ const document = this.readCandidateDocument(sessionId);
322
+ const candidate = document.candidates.find((item) => item.id === candidateId);
323
+ if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
324
+ if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
325
+
326
+ if (args.trap) candidate.trap = args.trap;
327
+ candidate.status = "accepted";
328
+ candidate.accepted_trap_id = args.trapId;
329
+ candidate.accepted_scope = args.scope === "project" ? "project" : "global";
330
+ candidate.accepted_at = now.toISOString();
331
+ candidate.quality.conflict_checked = args.conflictChecked ?? candidate.quality.conflict_checked;
332
+ candidate.quality.conflict_status = args.conflictStatus ?? candidate.quality.conflict_status;
333
+ candidate.quality.suggested_action = args.suggestedAction ?? candidate.quality.suggested_action;
334
+ this.writeCandidateDocument(session.id, document.candidates);
335
+ this.refreshSessionSummaries(session.id);
336
+
337
+ return {
338
+ session,
339
+ candidate,
340
+ trap_id: args.trapId,
341
+ scope: args.scope,
342
+ evidence_id: args.evidenceId,
343
+ superseded_id: args.supersededId ?? null,
344
+ };
345
+ }
346
+
347
+ rejectCandidate(
348
+ candidateId: string,
349
+ args: { sessionId?: string; reason?: string | null },
350
+ now = new Date()
351
+ ): { session: SessionMetadata; candidate: CandidateTrap } {
352
+ const sessionId = this.resolveSessionId(args.sessionId);
353
+ const session = this.requireSession(sessionId);
354
+ const document = this.readCandidateDocument(sessionId);
355
+ const candidate = document.candidates.find((item) => item.id === candidateId);
356
+ if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
357
+ if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
358
+
359
+ candidate.status = "rejected";
360
+ candidate.rejected_at = now.toISOString();
361
+ if (args.reason) candidate.rejection_reason = args.reason;
362
+ this.writeCandidateDocument(session.id, document.candidates);
363
+ this.refreshSessionSummaries(session.id);
364
+ return { session, candidate };
365
+ }
366
+
367
+ removeCandidates(sessionId: string | undefined, candidateIds: string[]): RemoveSessionCandidatesResult {
368
+ const resolvedSessionId = this.resolveSessionId(sessionId);
369
+ const session = this.requireSession(resolvedSessionId);
370
+ const removeIds = new Set(uniqueStrings(candidateIds));
371
+ if (removeIds.size === 0) {
372
+ return {
373
+ session,
374
+ removed_count: 0,
375
+ removed_candidate_ids: [],
376
+ candidates: this.readCandidateDocument(session.id).candidates,
377
+ };
378
+ }
379
+
380
+ const document = this.readCandidateDocument(session.id);
381
+ const removed = document.candidates.filter((candidate) => removeIds.has(candidate.id));
382
+ const candidates = document.candidates.filter((candidate) => !removeIds.has(candidate.id));
383
+ if (removed.length > 0) {
384
+ this.writeCandidateDocument(session.id, candidates);
385
+ this.refreshSessionSummaries(session.id);
386
+ }
387
+
388
+ return {
389
+ session,
390
+ removed_count: removed.length,
391
+ removed_candidate_ids: removed.map((candidate) => candidate.id),
392
+ candidates,
393
+ };
394
+ }
395
+
396
+ deleteSession(id: string): DeleteSessionResult {
397
+ this.requireSession(id);
398
+ const active_cleared = this.readActive()?.active_session_id === id;
399
+ rmSync(this.sessionDir(id), { recursive: true, force: true });
400
+ this.removeIndexEntry(id);
401
+ if (active_cleared) this.clearActive();
402
+ return {
403
+ session_id: id,
404
+ deleted: true,
405
+ active_cleared,
406
+ session_dir: sessionRelativeDir(id),
407
+ };
408
+ }
409
+
410
+ pruneSessions(args: { cutoff: Date; dryRun?: boolean }): PruneSessionsResult {
411
+ const cutoffTime = args.cutoff.getTime();
412
+ const candidates = this.readIndex().sessions
413
+ .filter((entry) => entry.status === "closed")
414
+ .filter((entry) => {
415
+ const closedAt = entry.closed_at ? Date.parse(entry.closed_at) : Date.parse(entry.created_at);
416
+ return Number.isFinite(closedAt) && closedAt < cutoffTime;
417
+ });
418
+
419
+ if (!args.dryRun) {
420
+ for (const session of candidates) {
421
+ this.deleteSession(session.id);
422
+ }
423
+ }
424
+
425
+ return {
426
+ cutoff: args.cutoff.toISOString(),
427
+ dry_run: args.dryRun ?? false,
428
+ deleted_count: args.dryRun ? 0 : candidates.length,
429
+ sessions: candidates.map((session) => ({
430
+ id: session.id,
431
+ goal: session.goal,
432
+ status: session.status,
433
+ created_at: session.created_at,
434
+ closed_at: session.closed_at,
435
+ })),
436
+ };
437
+ }
438
+
439
+ readNotes(id: string): SessionNote[] {
440
+ return parseSessionNotes(this.readOptionalText(this.notesPath(id)) ?? "");
441
+ }
442
+
443
+ private activeSession(): SessionMetadata | null {
444
+ const active = this.readActive();
445
+ if (active?.active_session_id) {
446
+ const session = this.getSessionOrNull(active.active_session_id);
447
+ if (session?.status === "active") return session;
448
+ this.clearActive();
449
+ }
450
+ return this.readIndex().sessions
451
+ .filter((entry) => entry.status === "active")
452
+ .sort((a, b) => b.created_at.localeCompare(a.created_at))
453
+ .map((entry) => this.getSessionOrNull(entry.id))
454
+ .find((session): session is SessionMetadata => session !== null) ?? null;
455
+ }
456
+
457
+ private resolveSessionId(id?: string, opts: { requireActive?: boolean } = {}): string {
458
+ if (id) return id;
459
+ const active = this.activeSession();
460
+ if (active) return active.id;
461
+ if (opts.requireActive) throw new Error("No active session. Start one with `codetrap session start <goal>`.");
462
+ const latest = this.listSessions({ status: "all", limit: 1 })[0];
463
+ if (!latest) throw new Error("No sessions found.");
464
+ return latest.id;
465
+ }
466
+
467
+ private requireActiveSession(): SessionMetadata {
468
+ const active = this.activeSession();
469
+ if (!active) throw new Error("No active session. Start one with `codetrap session start <goal>`.");
470
+ return active;
471
+ }
472
+
473
+ private requireSession(id: string): SessionMetadata {
474
+ const session = this.getSessionOrNull(id);
475
+ if (!session) throw new Error(`Session ${id} not found.`);
476
+ return session;
477
+ }
478
+
479
+ private getSessionOrNull(id: string): SessionMetadata | null {
480
+ const path = this.sessionJsonPath(id);
481
+ if (!existsSync(path)) return null;
482
+ return JSON.parse(readFileSync(path, "utf-8")) as SessionMetadata;
483
+ }
484
+
485
+ private findCandidate(sessionId: string, candidateId: string): CandidateTrap {
486
+ const candidate = this.readCandidateDocument(sessionId).candidates.find((item) => item.id === candidateId);
487
+ if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
488
+ return candidate;
489
+ }
490
+
491
+ private readCandidateDocument(id: string): CandidateTrapDocument {
492
+ const path = this.candidatesPath(id);
493
+ if (!existsSync(path)) {
494
+ return { version: SESSION_VERSION, session_id: id, candidates: [] };
495
+ }
496
+ return JSON.parse(readFileSync(path, "utf-8")) as CandidateTrapDocument;
497
+ }
498
+
499
+ private writeCandidateDocument(id: string, candidates: CandidateTrap[]): void {
500
+ writeFileSync(this.candidatesPath(id), `${JSON.stringify({
501
+ version: SESSION_VERSION,
502
+ session_id: id,
503
+ candidates,
504
+ } satisfies CandidateTrapDocument, null, 2)}\n`);
505
+ }
506
+
507
+ private refreshSessionSummaries(id: string): void {
508
+ const session = this.requireSession(id);
509
+ const notes = this.readNotes(id);
510
+ const candidates = this.readCandidateDocument(id).candidates;
511
+ writeFileSync(this.recapPath(id), formatRecap(session, notes, candidates));
512
+ this.writeIndexEntry(session, notes, candidates, recapSummary(session, notes, candidates));
513
+ }
514
+
515
+ private writeSession(session: SessionMetadata): void {
516
+ writeFileSync(this.sessionJsonPath(session.id), `${JSON.stringify(session, null, 2)}\n`);
517
+ }
518
+
519
+ private readIndex(): SessionIndexDocument {
520
+ const path = this.indexPath();
521
+ if (!existsSync(path)) return { version: SESSION_VERSION, sessions: [] };
522
+ return JSON.parse(readFileSync(path, "utf-8")) as SessionIndexDocument;
523
+ }
524
+
525
+ private writeIndexEntry(
526
+ session: SessionMetadata,
527
+ notes: SessionNote[],
528
+ candidates: CandidateTrap[],
529
+ summary: string | null
530
+ ): void {
531
+ this.ensureSessionsDir();
532
+ const index = this.readIndex();
533
+ const existing = index.sessions.find((entry) => entry.id === session.id);
534
+ const entry = sessionIndexEntry(session, notes, candidates, summary ?? existing?.summary ?? null);
535
+ const sessions = [entry, ...index.sessions.filter((item) => item.id !== session.id)]
536
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
537
+ writeFileSync(this.indexPath(), `${JSON.stringify({ version: SESSION_VERSION, sessions } satisfies SessionIndexDocument, null, 2)}\n`);
538
+ }
539
+
540
+ private removeIndexEntry(id: string): void {
541
+ this.ensureSessionsDir();
542
+ const index = this.readIndex();
543
+ const sessions = index.sessions.filter((entry) => entry.id !== id);
544
+ writeFileSync(this.indexPath(), `${JSON.stringify({ version: SESSION_VERSION, sessions } satisfies SessionIndexDocument, null, 2)}\n`);
545
+ }
546
+
547
+ private readActive(): { active_session_id: string | null; updated_at: string } | null {
548
+ const path = this.activePath();
549
+ if (!existsSync(path)) return null;
550
+ return JSON.parse(readFileSync(path, "utf-8")) as { active_session_id: string | null; updated_at: string };
551
+ }
552
+
553
+ private writeActive(id: string): void {
554
+ writeFileSync(this.activePath(), `${JSON.stringify({
555
+ active_session_id: id,
556
+ updated_at: new Date().toISOString(),
557
+ }, null, 2)}\n`);
558
+ }
559
+
560
+ private clearActive(): void {
561
+ this.ensureSessionsDir();
562
+ writeFileSync(this.activePath(), `${JSON.stringify({
563
+ active_session_id: null,
564
+ updated_at: new Date().toISOString(),
565
+ }, null, 2)}\n`);
566
+ }
567
+
568
+ private readOptionalText(path: string): string | null {
569
+ return existsSync(path) ? readFileSync(path, "utf-8") : null;
570
+ }
571
+
572
+ private ensureSessionsDir(): void {
573
+ mkdirSync(this.sessionsDir(), { recursive: true });
574
+ }
575
+
576
+ private sessionsDir(): string {
577
+ return join(this.projectRoot, CODETRAP_DIR, SESSIONS_DIR);
578
+ }
579
+
580
+ private sessionDir(id: string): string {
581
+ return join(this.sessionsDir(), id);
582
+ }
583
+
584
+ private activePath(): string {
585
+ return join(this.sessionsDir(), ACTIVE_SESSION_FILE);
586
+ }
587
+
588
+ private indexPath(): string {
589
+ return join(this.sessionsDir(), SESSION_INDEX_FILE);
590
+ }
591
+
592
+ private sessionJsonPath(id: string): string {
593
+ return join(this.sessionDir(id), SESSION_FILE);
594
+ }
595
+
596
+ private notesPath(id: string): string {
597
+ return join(this.sessionDir(id), NOTES_FILE);
598
+ }
599
+
600
+ private recapPath(id: string): string {
601
+ return join(this.sessionDir(id), RECAP_FILE);
602
+ }
603
+
604
+ private candidatesPath(id: string): string {
605
+ return join(this.sessionDir(id), CANDIDATES_FILE);
606
+ }
607
+ }
608
+
609
+ function uniqueStrings(values: string[]): string[] {
610
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
611
+ }
package/src/lib/store.ts CHANGED
@@ -38,9 +38,13 @@ export class TrapStore {
38
38
  private readonly scopes: ScopedRepositoryContext;
39
39
  private readonly embedder?: EmbeddingProvider;
40
40
 
41
- constructor(cwd: string, embedder: EmbeddingProvider | undefined = createDefaultEmbeddingProvider()) {
41
+ constructor(
42
+ cwd: string,
43
+ embedder: EmbeddingProvider | undefined = createDefaultEmbeddingProvider(),
44
+ private readonly home?: string
45
+ ) {
42
46
  this.embedder = embedder;
43
- this.scopes = new ScopedRepositoryContext(cwd, embedder);
47
+ this.scopes = new ScopedRepositoryContext(cwd, embedder, home);
44
48
  }
45
49
 
46
50
  add(input: TrapInput): { id: number; scope: string } {
@@ -247,7 +251,7 @@ export class TrapStore {
247
251
  }
248
252
 
249
253
  forCwd(cwd: string): TrapStore {
250
- return new TrapStore(cwd, this.embedder);
254
+ return new TrapStore(cwd, this.embedder, this.home);
251
255
  }
252
256
 
253
257
  async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{