codetrap 0.1.7 → 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 (58) hide show
  1. package/README.md +151 -52
  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 +144 -68
  15. package/src/db/embedding-queries.ts +230 -48
  16. package/src/db/queries.ts +0 -25
  17. package/src/db/repository.ts +32 -21
  18. package/src/db/schema.ts +80 -0
  19. package/src/index.ts +28 -3
  20. package/src/lib/command-requests.ts +112 -1
  21. package/src/lib/config.ts +57 -7
  22. package/src/lib/constants.ts +1 -1
  23. package/src/lib/doctor.ts +42 -12
  24. package/src/lib/embedder.ts +118 -3
  25. package/src/lib/embedding-health.ts +3 -1
  26. package/src/lib/embedding-job.ts +3 -0
  27. package/src/lib/embedding-management.ts +65 -0
  28. package/src/lib/embedding-runtime.ts +177 -0
  29. package/src/lib/output-json.ts +0 -2
  30. package/src/lib/scope-context.ts +12 -6
  31. package/src/lib/scope-migration.ts +2 -1
  32. package/src/lib/scope.ts +0 -2
  33. package/src/lib/search-eval.ts +38 -18
  34. package/src/lib/search-policy-sweep.ts +563 -0
  35. package/src/lib/search-policy.ts +0 -4
  36. package/src/lib/search-service.ts +14 -15
  37. package/src/lib/session-candidate-document.ts +175 -0
  38. package/src/lib/session-candidate-scope.ts +6 -0
  39. package/src/lib/session-capture.ts +298 -32
  40. package/src/lib/session-codec.ts +1 -8
  41. package/src/lib/session-operations.ts +83 -60
  42. package/src/lib/session-review.ts +327 -0
  43. package/src/lib/session-store.ts +87 -73
  44. package/src/lib/store.ts +74 -10
  45. package/src/lib/string-list.ts +3 -0
  46. package/src/lib/text-lines.ts +7 -0
  47. package/src/lib/trap-search-document.ts +2 -1
  48. package/src/lib/value-types.ts +3 -0
  49. package/src/web/client-review.ts +171 -0
  50. package/src/web/client-script.ts +426 -51
  51. package/src/web/client-shell.ts +414 -0
  52. package/src/web/client-text.ts +112 -0
  53. package/src/web/project-registry.ts +3 -5
  54. package/src/web/server.ts +117 -103
  55. package/src/web/static.ts +364 -19
  56. package/skills/codetrap-capture-external/SKILL.md +0 -62
  57. package/skills/codetrap-check/SKILL.md +0 -69
  58. package/src/lib/embedding-index.ts +0 -53
package/src/db/queries.ts CHANGED
@@ -179,10 +179,6 @@ export function listTrapEvidence(db: Database, trapId: number): TrapEvidence[] {
179
179
  .all(trapId) as TrapEvidence[];
180
180
  }
181
181
 
182
- export function archiveTrap(db: Database, id: number): boolean {
183
- return markTrapArchived(db, id);
184
- }
185
-
186
182
  export function markTrapArchived(db: Database, id: number): boolean {
187
183
  const result = db
188
184
  .prepare(
@@ -198,27 +194,6 @@ export function markTrapArchived(db: Database, id: number): boolean {
198
194
  return result.changes > 0;
199
195
  }
200
196
 
201
- export function supersedeTrap(
202
- db: Database,
203
- id: number,
204
- supersededById: number,
205
- stateKey?: string
206
- ): boolean {
207
- if (id === supersededById) return false;
208
-
209
- const oldTrap = getTrap(db, id);
210
- const newTrap = getTrap(db, supersededById);
211
- if (!oldTrap || !newTrap) return false;
212
-
213
- const key = stateKey ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${id}`;
214
- const tx = db.transaction(() => {
215
- markTrapSuperseded(db, id, key);
216
- markTrapSuperseding(db, supersededById, id, key);
217
- });
218
- tx();
219
- return true;
220
- }
221
-
222
197
  export function markTrapSuperseded(db: Database, id: number, stateKey: string): boolean {
223
198
  const result = db.prepare(supersedeTrapSql).run(stateKey, id);
224
199
  return result.changes > 0;
@@ -11,32 +11,39 @@ import type {
11
11
  import { SearchService, type SearchOptions } from "../lib/search-service";
12
12
  import {
13
13
  type EmbeddingConfig,
14
- type EmbeddingProvider,
15
14
  type StoredEmbedding,
16
15
  } from "../lib/embedder";
16
+ import {
17
+ embeddingRuntimeFrom,
18
+ type EmbeddingRuntime,
19
+ type EmbeddingRuntimeInput,
20
+ } from "../lib/embedding-runtime";
17
21
  import { runEmbeddingJob } from "../lib/embedding-job";
18
22
  import { passageFieldsChanged } from "../lib/trap-search-document";
23
+ import * as embeddingQueries from "./embedding-queries";
19
24
  import * as queries from "./queries";
20
25
  import type { TrapStatus } from "../lib/constants";
21
26
  import { TrapSearchPolicy } from "../lib/search-policy";
22
- import { DatabaseEmbeddingIndex } from "../lib/embedding-index";
27
+ import type { RankingConfig } from "../lib/search-policy";
23
28
  import { archiveTrapLifecycle, supersedeTrapLifecycle } from "../lib/trap-lifecycle";
29
+ import type { EmbeddingProfileSummary } from "./embedding-queries";
24
30
 
25
31
  export type TrapStats = ReturnType<typeof queries.getStats>;
26
- export type EmbeddingStateCounts = ReturnType<DatabaseEmbeddingIndex["stateCounts"]>;
32
+ export type EmbeddingStateCounts = ReturnType<typeof embeddingQueries.getEmbeddingStateCounts>;
27
33
  export type TrapRecordInsert = queries.TrapRecordInsert;
28
34
 
29
35
  export class TrapRepository {
30
36
  private readonly searchService: SearchService;
31
37
  private readonly searchPolicy = new TrapSearchPolicy();
32
- private readonly embeddingIndex: DatabaseEmbeddingIndex;
38
+ private readonly embeddings: EmbeddingRuntime;
33
39
 
34
40
  constructor(
35
41
  private readonly db: Database,
36
- private readonly embedder?: EmbeddingProvider
42
+ embeddings?: EmbeddingRuntimeInput,
43
+ ranking?: RankingConfig
37
44
  ) {
38
- this.searchService = new SearchService(db, embedder);
39
- this.embeddingIndex = new DatabaseEmbeddingIndex(db);
45
+ this.embeddings = embeddingRuntimeFrom(embeddings);
46
+ this.searchService = new SearchService(db, this.embeddings, ranking);
40
47
  }
41
48
 
42
49
  add(input: TrapInput): number {
@@ -79,7 +86,7 @@ export class TrapRepository {
79
86
  update(id: number, input: TrapUpdate): boolean {
80
87
  const success = queries.updateTrap(this.db, id, input);
81
88
  if (success && passageFieldsChanged(input)) {
82
- this.embeddingIndex.delete(id);
89
+ embeddingQueries.deleteEmbedding(this.db, id);
83
90
  }
84
91
  return success;
85
92
  }
@@ -114,7 +121,11 @@ export class TrapRepository {
114
121
  }
115
122
 
116
123
  embeddingStats(config: EmbeddingConfig | null, opts: { scope?: string; status?: TrapStatus | "all" } = {}): EmbeddingStateCounts {
117
- return this.embeddingIndex.stateCounts(config, opts);
124
+ return embeddingQueries.getEmbeddingStateCounts(this.db, config, opts);
125
+ }
126
+
127
+ embeddingProfiles(opts: { scope?: string; status?: TrapStatus | "all" } = {}): EmbeddingProfileSummary[] {
128
+ return embeddingQueries.listEmbeddingProfiles(this.db, opts);
118
129
  }
119
130
 
120
131
  exportAll(): TrapExportRecord[] {
@@ -145,23 +156,23 @@ export class TrapRepository {
145
156
  return this.db.transaction(callback)();
146
157
  }
147
158
 
148
- getEmbedding(trapId: number): StoredEmbedding | null {
149
- return this.embeddingIndex.get(trapId);
159
+ getEmbedding(trapId: number, config?: EmbeddingConfig): StoredEmbedding | null {
160
+ return embeddingQueries.getEmbedding(this.db, trapId, config);
150
161
  }
151
162
 
152
163
  upsertEmbedding(record: StoredEmbedding): void {
153
- this.embeddingIndex.save(record);
164
+ embeddingQueries.upsertEmbedding(this.db, record);
154
165
  }
155
166
 
156
167
  deleteEmbedding(trapId: number): void {
157
- this.embeddingIndex.delete(trapId);
168
+ embeddingQueries.deleteEmbedding(this.db, trapId);
158
169
  }
159
170
 
160
171
  getTrapsNeedingEmbeddings(
161
172
  config: EmbeddingConfig,
162
173
  opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
163
174
  ): Trap[] {
164
- return this.embeddingIndex.trapsNeedingEmbeddings(config, opts);
175
+ return embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, opts);
165
176
  }
166
177
 
167
178
  async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
@@ -169,18 +180,18 @@ export class TrapRepository {
169
180
  skipped: number;
170
181
  batches: number;
171
182
  }> {
172
- if (!this.embedder) {
173
- throw new Error("Embedding provider is unavailable. Set JINA_API_KEY to generate embeddings.");
174
- }
183
+ const provider = this.embeddings.requireProvider(
184
+ "Embedding provider is unavailable. Configure an embedding provider to generate embeddings."
185
+ );
175
186
 
176
187
  return runEmbeddingJob(
177
188
  {
178
- countEmbeddable: (countOpts) => this.embeddingIndex.countEmbeddable(countOpts),
189
+ countEmbeddable: (countOpts) => embeddingQueries.countEmbeddableTraps(this.db, countOpts),
179
190
  trapsNeedingEmbeddings: (config, jobOpts) =>
180
- this.embeddingIndex.trapsNeedingEmbeddings(config, jobOpts),
181
- saveEmbedding: (record) => this.embeddingIndex.save(record),
191
+ embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, jobOpts),
192
+ saveEmbedding: (record) => embeddingQueries.upsertEmbedding(this.db, record),
182
193
  },
183
- this.embedder,
194
+ provider,
184
195
  opts
185
196
  );
186
197
  }
package/src/db/schema.ts CHANGED
@@ -175,6 +175,86 @@ function applyMigrations(db: Database, from: number): void {
175
175
 
176
176
  db.prepare("UPDATE schema_version SET version = ?").run(5);
177
177
  }
178
+
179
+ if (from < 6) {
180
+ migrateEmbeddingProfiles(db);
181
+ db.prepare("UPDATE schema_version SET version = ?").run(6);
182
+ }
183
+ }
184
+
185
+ function migrateEmbeddingProfiles(db: Database): void {
186
+ db.exec(`
187
+ CREATE TABLE IF NOT EXISTS embedding_profiles (
188
+ id TEXT PRIMARY KEY,
189
+ provider TEXT NOT NULL,
190
+ model TEXT NOT NULL,
191
+ dimensions INTEGER NOT NULL,
192
+ passage_version INTEGER NOT NULL,
193
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
194
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
195
+ );
196
+ `);
197
+
198
+ const hasTrapEmbeddings = tableExists(db, "trap_embeddings");
199
+ if (!hasTrapEmbeddings || columnExists(db, "trap_embeddings", "profile_id")) {
200
+ createProfileAwareEmbeddingsTable(db);
201
+ return;
202
+ }
203
+
204
+ db.exec("ALTER TABLE trap_embeddings RENAME TO trap_embeddings_legacy_v5");
205
+ createProfileAwareEmbeddingsTable(db);
206
+
207
+ db.exec(`
208
+ INSERT OR IGNORE INTO embedding_profiles (
209
+ id, provider, model, dimensions, passage_version, created_at, updated_at
210
+ )
211
+ SELECT
212
+ provider || ':' || model || ':' || dimensions || ':p' || passage_version,
213
+ provider,
214
+ model,
215
+ dimensions,
216
+ passage_version,
217
+ MIN(updated_at),
218
+ MAX(updated_at)
219
+ FROM trap_embeddings_legacy_v5
220
+ GROUP BY provider, model, dimensions, passage_version;
221
+
222
+ INSERT OR REPLACE INTO trap_embeddings (
223
+ trap_id, profile_id, passage_hash, embedding, updated_at
224
+ )
225
+ SELECT
226
+ trap_id,
227
+ provider || ':' || model || ':' || dimensions || ':p' || passage_version,
228
+ passage_hash,
229
+ embedding,
230
+ updated_at
231
+ FROM trap_embeddings_legacy_v5;
232
+
233
+ DROP TABLE trap_embeddings_legacy_v5;
234
+ `);
235
+ }
236
+
237
+ function createProfileAwareEmbeddingsTable(db: Database): void {
238
+ db.exec(`
239
+ CREATE TABLE IF NOT EXISTS trap_embeddings (
240
+ trap_id INTEGER NOT NULL REFERENCES traps(id) ON DELETE CASCADE,
241
+ profile_id TEXT NOT NULL REFERENCES embedding_profiles(id) ON DELETE CASCADE,
242
+ passage_hash TEXT NOT NULL,
243
+ embedding BLOB NOT NULL,
244
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
245
+ PRIMARY KEY (trap_id, profile_id)
246
+ );
247
+
248
+ CREATE INDEX IF NOT EXISTS idx_trap_embeddings_profile
249
+ ON trap_embeddings(profile_id);
250
+ `);
251
+ }
252
+
253
+ function tableExists(db: Database, table: string): boolean {
254
+ const row = db
255
+ .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
256
+ .get(table) as { name: string } | null;
257
+ return row !== null;
178
258
  }
179
259
 
180
260
  function columnExists(db: Database, table: string, column: string): boolean {
package/src/index.ts CHANGED
@@ -15,8 +15,12 @@ if (args.length === 0) {
15
15
  } else if (args[0] === "serve") {
16
16
  import("./mcp/server").then((m) => m.start());
17
17
  } else if (args[0] === "web") {
18
- const { startWebServerFromArgs } = await import("./web/server");
19
- await startWebServerFromArgs(args.slice(1));
18
+ if (args.slice(1).some(isHelpArg)) {
19
+ showWebHelp();
20
+ } else {
21
+ const { startWebServerFromArgs } = await import("./web/server");
22
+ await startWebServerFromArgs(args.slice(1));
23
+ }
20
24
  } else if (args[0] === "init") {
21
25
  const cwd = process.cwd();
22
26
  if (findProjectRoot(cwd)) {
@@ -31,6 +35,10 @@ if (args.length === 0) {
31
35
  await run(args, store);
32
36
  }
33
37
 
38
+ function isHelpArg(arg: string | undefined): boolean {
39
+ return arg === "--help" || arg === "-h" || arg === "help";
40
+ }
41
+
34
42
  function showHelp(): void {
35
43
  console.log("codetrap — capture coding pitfalls so AI doesn't repeat mistakes");
36
44
  console.log("");
@@ -45,7 +53,8 @@ function showHelp(): void {
45
53
  console.log(" add_trap_evidence Attach evidence to a trap");
46
54
  console.log(" archive_trap Archive a trap");
47
55
  console.log(" supersede_trap Mark one trap as superseded by another");
48
- console.log(" embed Generate embeddings for semantic search");
56
+ console.log(" embed Generate embeddings for semantic search (Ollama or Jina)");
57
+ console.log(" embeddings Manage embedding profiles, provider config, and reindexing");
49
58
  console.log(" session Record implementation notes and capture candidate traps");
50
59
  console.log(" web Start local review and trap library console");
51
60
  console.log(" export Export traps as JSON");
@@ -77,3 +86,19 @@ function showHelp(): void {
77
86
  console.log(" --to-project-path <path> Destination project path for scope repair/migration");
78
87
  console.log(" --dry-run|--apply Preview or apply scope repair/migration");
79
88
  }
89
+
90
+ function showWebHelp(): void {
91
+ console.log("codetrap web — start the local review and trap library console");
92
+ console.log("");
93
+ console.log("Usage:");
94
+ console.log(" codetrap web [--project <path>] [--host <host>] [--port <n>]");
95
+ console.log("");
96
+ console.log("Options:");
97
+ console.log(" --project <path> Project path to open in the web console");
98
+ console.log(" --host <host> Host to bind (default 127.0.0.1)");
99
+ console.log(" --port <n> Port to try first (default 4737; next free port is used if busy)");
100
+ console.log("");
101
+ console.log("Examples:");
102
+ console.log(" codetrap web");
103
+ console.log(" codetrap web --project /path/to/project --port 4789");
104
+ }
@@ -1,5 +1,12 @@
1
1
  import { SEARCH_MODES, type SearchMode } from "./constants";
2
- import type { SearchDefaults } from "./config";
2
+ import type { EmbeddingProviderSetting, EmbeddingSettings, SearchDefaults } from "./config";
3
+ import {
4
+ DEFAULT_OLLAMA_DIMENSIONS,
5
+ DEFAULT_OLLAMA_ENDPOINT,
6
+ DEFAULT_OLLAMA_MODEL,
7
+ } from "./embedder";
8
+ import { capturedTrapMarkdownInput } from "./session-capture";
9
+ import { uniqueStrings as uniqueStringList } from "./string-list";
3
10
  import type { SearchTrapsArgs, ListTrapsArgs } from "./trap-operations";
4
11
 
5
12
  type RawArgs = Record<string, unknown>;
@@ -12,6 +19,10 @@ export type EmbedRequest = {
12
19
  batchSize?: number;
13
20
  };
14
21
 
22
+ export type EmbeddingsUseRequest = {
23
+ embeddings: EmbeddingSettings;
24
+ };
25
+
15
26
  export type StatsRequest = {
16
27
  scope?: string;
17
28
  };
@@ -40,6 +51,15 @@ export type SessionCloseRequest = {
40
51
  proposeTraps: boolean;
41
52
  };
42
53
 
54
+ export type SessionCaptureRequest = {
55
+ trap: Record<string, unknown>;
56
+ goal?: string;
57
+ kind?: string;
58
+ relatedFiles?: string[];
59
+ sourceRef?: string;
60
+ evidenceNote?: string;
61
+ };
62
+
43
63
  export type SessionIdRequest = {
44
64
  sessionId?: string;
45
65
  };
@@ -73,6 +93,12 @@ export type SessionNoteStdin = {
73
93
  read: () => string;
74
94
  };
75
95
 
96
+ export type SessionCaptureInput = {
97
+ isTTY: boolean;
98
+ readStdin: () => string;
99
+ readFile: (path: string) => string;
100
+ };
101
+
76
102
  export function searchRequestFromArgs(query: string, args: RawArgs, defaults: SearchDefaults): SearchTrapsArgs {
77
103
  return {
78
104
  query,
@@ -117,6 +143,27 @@ export function embedRequestFromArgs(args: RawArgs): EmbedRequest {
117
143
  };
118
144
  }
119
145
 
146
+ export function embeddingsUseRequestFromArgs(
147
+ positionals: string[],
148
+ args: RawArgs
149
+ ): EmbeddingsUseRequest {
150
+ const provider = embeddingProviderOption(requiredPositional(positionals, 0, "provider"));
151
+ if (provider === "jina") {
152
+ return {
153
+ embeddings: { provider },
154
+ };
155
+ }
156
+
157
+ return {
158
+ embeddings: {
159
+ provider,
160
+ endpoint: stringOption(args, "endpoint") ?? DEFAULT_OLLAMA_ENDPOINT,
161
+ model: stringOption(args, "model") ?? DEFAULT_OLLAMA_MODEL,
162
+ dimensions: optionalIntOption(args, "dimensions") ?? DEFAULT_OLLAMA_DIMENSIONS,
163
+ },
164
+ };
165
+ }
166
+
120
167
  export function evidenceRequestFromArgs(args: RawArgs): RawArgs {
121
168
  return {
122
169
  source_type: stringOption(args, "source_type", "source-type"),
@@ -175,6 +222,35 @@ export function sessionCloseRequestFromArgs(positionals: string[], args: RawArgs
175
222
  };
176
223
  }
177
224
 
225
+ export function sessionCaptureRequestFromArgs(args: RawArgs, input?: SessionCaptureInput): SessionCaptureRequest {
226
+ const sources = [
227
+ args["trap-json"] !== undefined,
228
+ args["trap-markdown"] !== undefined,
229
+ args["trap-markdown-file"] !== undefined,
230
+ ].filter(Boolean).length;
231
+ if (sources === 0) throw new Error("--trap-json, --trap-markdown, or --trap-markdown-file is required.");
232
+ if (sources > 1) throw new Error("Choose only one of --trap-json, --trap-markdown, or --trap-markdown-file.");
233
+
234
+ const markdown = markdownCaptureInput(args, input);
235
+ const parsedMarkdown = markdown === undefined ? null : capturedTrapMarkdownInput(markdown);
236
+ const trap: Record<string, unknown> | undefined = parsedMarkdown
237
+ ? { ...parsedMarkdown.trap }
238
+ : jsonObjectOption(args, "trap-json");
239
+ if (!trap) throw new Error("--trap-json, --trap-markdown, or --trap-markdown-file is required.");
240
+
241
+ return {
242
+ trap,
243
+ goal: stringOption(args, "goal"),
244
+ kind: stringOption(args, "kind"),
245
+ relatedFiles: uniqueStrings([
246
+ ...(csvOrArrayOption(args, "related_files", "related-files") ?? []),
247
+ ...(parsedMarkdown?.relatedFiles ?? []),
248
+ ]),
249
+ sourceRef: stringOption(args, "source_ref", "source-ref"),
250
+ evidenceNote: parsedMarkdown?.evidenceNote,
251
+ };
252
+ }
253
+
178
254
  export function sessionIdRequestFromArgs(positionals: string[]): SessionIdRequest {
179
255
  return {
180
256
  sessionId: positionals[0],
@@ -249,6 +325,11 @@ function searchModeOption(args: RawArgs, key: string): SearchMode | undefined {
249
325
  throw new Error(`Invalid search mode: ${value}. Expected one of: ${SEARCH_MODES.join(", ")}`);
250
326
  }
251
327
 
328
+ function embeddingProviderOption(value: string): EmbeddingProviderSetting {
329
+ if (value === "ollama" || value === "jina") return value;
330
+ throw new Error(`Invalid embedding provider: ${value}. Expected ollama or jina.`);
331
+ }
332
+
252
333
  function booleanOption(args: RawArgs, ...keys: string[]): boolean | undefined {
253
334
  for (const key of keys) {
254
335
  const value = args[key];
@@ -296,6 +377,36 @@ function jsonObjectOption(args: RawArgs, key: string): Record<string, unknown> |
296
377
  }
297
378
  }
298
379
 
380
+ function markdownCaptureInput(args: RawArgs, input: SessionCaptureInput | undefined): string | undefined {
381
+ const inline = stringOption(args, "trap-markdown");
382
+ const file = stringOption(args, "trap-markdown-file");
383
+ if (inline === undefined && file === undefined) return undefined;
384
+
385
+ const text = file !== undefined
386
+ ? readMarkdownFile(file, input)
387
+ : inline === "-"
388
+ ? readMarkdownStdin(input)
389
+ : inline ?? "";
390
+ if (text.trim() === "") throw new Error("Markdown trap input is required.");
391
+ return text;
392
+ }
393
+
394
+ function readMarkdownStdin(input: SessionCaptureInput | undefined): string {
395
+ if (!input) throw new Error("--trap-markdown - requires stdin support.");
396
+ if (input.isTTY) throw new Error("--trap-markdown - requires piped input.");
397
+ return input.readStdin();
398
+ }
399
+
400
+ function readMarkdownFile(path: string, input: SessionCaptureInput | undefined): string {
401
+ if (!input) throw new Error("--trap-markdown-file requires file read support.");
402
+ return input.readFile(path);
403
+ }
404
+
405
+ function uniqueStrings(values: string[]): string[] | undefined {
406
+ const unique = uniqueStringList(values);
407
+ return unique.length > 0 ? unique : undefined;
408
+ }
409
+
299
410
  function parseDurationDays(value: string): number {
300
411
  const match = value.trim().match(/^(\d+)\s*(d|day|days)?$/i);
301
412
  if (!match) throw new Error(`Invalid duration: ${value}. Use a value like 90d.`);
package/src/lib/config.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { CODETRAP_DIR, SCOPES, SEARCH_MODES, type Scope, type SearchMode } from "./constants";
5
+ import { isRecord } from "./value-types";
5
6
 
6
7
  export type CodetrapConfig = {
7
8
  search?: {
@@ -10,6 +11,16 @@ export type CodetrapConfig = {
10
11
  scope?: Scope;
11
12
  rerank?: boolean;
12
13
  };
14
+ embeddings?: EmbeddingSettings;
15
+ };
16
+
17
+ export type EmbeddingProviderSetting = "ollama" | "jina";
18
+
19
+ export type EmbeddingSettings = {
20
+ provider?: EmbeddingProviderSetting;
21
+ endpoint?: string;
22
+ model?: string;
23
+ dimensions?: number;
13
24
  };
14
25
 
15
26
  export type SearchDefaults = {
@@ -19,6 +30,11 @@ export type SearchDefaults = {
19
30
  rerank: boolean;
20
31
  };
21
32
 
33
+ export type ConfigWriteResult = {
34
+ path: string;
35
+ config: CodetrapConfig;
36
+ };
37
+
22
38
  const BUILT_IN_SEARCH_DEFAULTS: SearchDefaults = {
23
39
  mode: "hybrid",
24
40
  limit: 20,
@@ -26,7 +42,7 @@ const BUILT_IN_SEARCH_DEFAULTS: SearchDefaults = {
26
42
  };
27
43
 
28
44
  export function loadCodetrapConfig(home = homedir()): CodetrapConfig {
29
- const path = join(home, CODETRAP_DIR, "config.json");
45
+ const path = codetrapConfigPath(home);
30
46
  if (!existsSync(path)) return {};
31
47
 
32
48
  try {
@@ -38,6 +54,26 @@ export function loadCodetrapConfig(home = homedir()): CodetrapConfig {
38
54
  }
39
55
  }
40
56
 
57
+ export function codetrapConfigPath(home = homedir()): string {
58
+ return join(home, CODETRAP_DIR, "config.json");
59
+ }
60
+
61
+ export function writeCodetrapConfig(config: CodetrapConfig, home = homedir()): ConfigWriteResult {
62
+ const path = codetrapConfigPath(home);
63
+ mkdirSync(join(home, CODETRAP_DIR), { recursive: true });
64
+ const normalized = normalizeConfig(config);
65
+ writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`);
66
+ return { path, config: normalized };
67
+ }
68
+
69
+ export function setCodetrapEmbeddingSettings(
70
+ embeddings: EmbeddingSettings,
71
+ home = homedir()
72
+ ): ConfigWriteResult {
73
+ const current = loadCodetrapConfig(home);
74
+ return writeCodetrapConfig({ ...current, embeddings }, home);
75
+ }
76
+
41
77
  export function searchDefaultsFromConfig(config = loadCodetrapConfig(), env = process.env): SearchDefaults {
42
78
  return {
43
79
  mode: config.search?.mode ?? parseSearchModeEnv(env.CODETRAP_SEARCH_MODE) ?? BUILT_IN_SEARCH_DEFAULTS.mode,
@@ -50,7 +86,11 @@ export function searchDefaultsFromConfig(config = loadCodetrapConfig(), env = pr
50
86
  function normalizeConfig(value: unknown): CodetrapConfig {
51
87
  if (!isRecord(value)) return {};
52
88
  const search = isRecord(value.search) ? normalizeSearchConfig(value.search) : undefined;
53
- return search ? { search } : {};
89
+ const embeddings = isRecord(value.embeddings) ? normalizeEmbeddingSettings(value.embeddings) : undefined;
90
+ return {
91
+ ...(search ? { search } : {}),
92
+ ...(embeddings ? { embeddings } : {}),
93
+ };
54
94
  }
55
95
 
56
96
  function normalizeSearchConfig(value: Record<string, unknown>): CodetrapConfig["search"] {
@@ -62,6 +102,15 @@ function normalizeSearchConfig(value: Record<string, unknown>): CodetrapConfig["
62
102
  return out;
63
103
  }
64
104
 
105
+ function normalizeEmbeddingSettings(value: Record<string, unknown>): EmbeddingSettings {
106
+ const out: EmbeddingSettings = {};
107
+ if (typeof value.provider === "string") out.provider = parseEmbeddingProvider(value.provider);
108
+ if (typeof value.endpoint === "string") out.endpoint = value.endpoint;
109
+ if (typeof value.model === "string") out.model = value.model;
110
+ if (typeof value.dimensions === "number") out.dimensions = parsePositiveInt(value.dimensions, "embeddings.dimensions");
111
+ return out;
112
+ }
113
+
65
114
  function parseSearchModeEnv(value?: string): SearchMode | undefined {
66
115
  return value ? parseSearchMode(value) : undefined;
67
116
  }
@@ -92,11 +141,12 @@ function parseScope(value: string): Scope {
92
141
  throw new Error(`Invalid scope: ${value}. Expected one of: ${SCOPES.join(", ")}`);
93
142
  }
94
143
 
144
+ function parseEmbeddingProvider(value: string): EmbeddingProviderSetting {
145
+ if (value === "ollama" || value === "jina") return value;
146
+ throw new Error("Invalid embeddings.provider: expected one of: ollama, jina");
147
+ }
148
+
95
149
  function parsePositiveInt(value: number, label: string): number {
96
150
  if (Number.isInteger(value) && value > 0) return value;
97
151
  throw new Error(`Invalid ${label}: expected a positive integer.`);
98
152
  }
99
-
100
- function isRecord(value: unknown): value is Record<string, unknown> {
101
- return typeof value === "object" && value !== null && !Array.isArray(value);
102
- }
@@ -49,7 +49,7 @@ export const DEFAULT_TRAP_STATUS: TrapStatus = "active";
49
49
 
50
50
  // Increment this when schema changes in a breaking way.
51
51
  // Migrations are stored in src/db/migrations.ts
52
- export const SCHEMA_VERSION = 5;
52
+ export const SCHEMA_VERSION = 6;
53
53
 
54
54
  // Directory and file names
55
55
  export const CODETRAP_DIR = ".codetrap";