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
@@ -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
  };
@@ -63,11 +83,22 @@ export type SessionRejectRequest = SessionCandidateRequest & {
63
83
  reason?: string;
64
84
  };
65
85
 
86
+ export type SessionPruneRequest = {
87
+ olderThanDays: number;
88
+ apply: boolean;
89
+ };
90
+
66
91
  export type SessionNoteStdin = {
67
92
  isTTY: boolean;
68
93
  read: () => string;
69
94
  };
70
95
 
96
+ export type SessionCaptureInput = {
97
+ isTTY: boolean;
98
+ readStdin: () => string;
99
+ readFile: (path: string) => string;
100
+ };
101
+
71
102
  export function searchRequestFromArgs(query: string, args: RawArgs, defaults: SearchDefaults): SearchTrapsArgs {
72
103
  return {
73
104
  query,
@@ -112,6 +143,27 @@ export function embedRequestFromArgs(args: RawArgs): EmbedRequest {
112
143
  };
113
144
  }
114
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
+
115
167
  export function evidenceRequestFromArgs(args: RawArgs): RawArgs {
116
168
  return {
117
169
  source_type: stringOption(args, "source_type", "source-type"),
@@ -170,6 +222,35 @@ export function sessionCloseRequestFromArgs(positionals: string[], args: RawArgs
170
222
  };
171
223
  }
172
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
+
173
254
  export function sessionIdRequestFromArgs(positionals: string[]): SessionIdRequest {
174
255
  return {
175
256
  sessionId: positionals[0],
@@ -205,6 +286,15 @@ export function sessionRejectRequestFromArgs(positionals: string[], args: RawArg
205
286
  };
206
287
  }
207
288
 
289
+ export function sessionPruneRequestFromArgs(args: RawArgs): SessionPruneRequest {
290
+ const olderThan = stringOption(args, "older_than", "older-than");
291
+ if (!olderThan) throw new Error("--older-than is required.");
292
+ return {
293
+ olderThanDays: parseDurationDays(olderThan),
294
+ apply: flagPresent(args, "apply"),
295
+ };
296
+ }
297
+
208
298
  function stringOption(args: RawArgs, ...keys: string[]): string | undefined {
209
299
  for (const key of keys) {
210
300
  const value = args[key];
@@ -235,6 +325,11 @@ function searchModeOption(args: RawArgs, key: string): SearchMode | undefined {
235
325
  throw new Error(`Invalid search mode: ${value}. Expected one of: ${SEARCH_MODES.join(", ")}`);
236
326
  }
237
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
+
238
333
  function booleanOption(args: RawArgs, ...keys: string[]): boolean | undefined {
239
334
  for (const key of keys) {
240
335
  const value = args[key];
@@ -282,6 +377,44 @@ function jsonObjectOption(args: RawArgs, key: string): Record<string, unknown> |
282
377
  }
283
378
  }
284
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
+
410
+ function parseDurationDays(value: string): number {
411
+ const match = value.trim().match(/^(\d+)\s*(d|day|days)?$/i);
412
+ if (!match) throw new Error(`Invalid duration: ${value}. Use a value like 90d.`);
413
+ const days = Number.parseInt(match[1], 10);
414
+ if (!Number.isInteger(days) || days <= 0) throw new Error(`Invalid duration: ${value}. Use a positive day count.`);
415
+ return days;
416
+ }
417
+
285
418
  function requiredPositional(positionals: string[], index: number, name: string): string {
286
419
  const value = positionals[index];
287
420
  if (!value) throw new Error(`${name} is required.`);
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";
package/src/lib/doctor.ts CHANGED
@@ -1,8 +1,15 @@
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 { EmbeddingRuntimeStatus } from "./embedding-runtime";
4
+ import type { EmbeddingStateSummary, EmbeddingStatsResult, HybridFallbackReason } from "./embedding-health";
4
5
  import { createScopeContext } from "./scope-context";
5
6
  import { hybridFallbackReason } from "./embedding-health";
7
+ import type { ProjectCandidateReviewSummary } from "./session-review";
8
+
9
+ export type DoctorNextAction = {
10
+ command: string;
11
+ reason: string;
12
+ };
6
13
 
7
14
  export type DoctorReport = {
8
15
  cwd: string;
@@ -23,18 +30,23 @@ export type DoctorReport = {
23
30
  global_db_project_traps: ReturnType<TrapStore["diagnostics"]>["mis_scoped_traps"]["global_db_project_traps"];
24
31
  };
25
32
  };
33
+ candidate_review: ProjectCandidateReviewSummary | null;
34
+ next_actions: DoctorNextAction[];
26
35
  mcp_hint: string;
27
36
  };
28
37
 
29
- export function buildDoctorReport(
38
+ export async function buildDoctorReport(
30
39
  store: TrapStore,
31
40
  operations: TrapOperations,
32
- cwd = process.cwd()
33
- ): DoctorReport {
41
+ cwd = process.cwd(),
42
+ candidateReview: ProjectCandidateReviewSummary | null = null
43
+ ): Promise<DoctorReport> {
34
44
  const scope = createScopeContext(cwd);
35
45
  const stats = operations.getStats();
36
46
  const embeddings = operations.getEmbeddingStats();
37
- const semanticAvailable = store.hasEmbeddingProvider();
47
+ const embeddingRuntime = await store.embeddingRuntimeHealth();
48
+ const semanticAvailable = embeddingRuntime.available;
49
+ const diagnostics = store.diagnostics();
38
50
 
39
51
  return {
40
52
  ...scope,
@@ -48,8 +60,10 @@ export function buildDoctorReport(
48
60
  fallback_reason: hybridFallbackReason(semanticAvailable, embeddings),
49
61
  },
50
62
  diagnostics: {
51
- mis_scoped_traps: store.diagnostics().mis_scoped_traps,
63
+ mis_scoped_traps: diagnostics.mis_scoped_traps,
52
64
  },
65
+ candidate_review: candidateReview,
66
+ next_actions: buildDoctorNextActions(embeddingRuntime, embeddings, diagnostics, candidateReview),
53
67
  mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
54
68
  };
55
69
  }
@@ -70,10 +84,86 @@ export function formatDoctorText(report: DoctorReport): string {
70
84
  ` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
71
85
  "Diagnostics:",
72
86
  ` global_db_project_traps: ${report.diagnostics.mis_scoped_traps.global_db_project_traps.length}`,
87
+ "Candidate review:",
88
+ formatCandidateReview(report.candidate_review),
89
+ "Next actions:",
90
+ ...formatNextActions(report.next_actions),
73
91
  `mcp_hint: ${report.mcp_hint}`,
74
92
  ].join("\n");
75
93
  }
76
94
 
95
+ function buildDoctorNextActions(
96
+ embeddingRuntime: EmbeddingRuntimeStatus,
97
+ embeddings: EmbeddingStatsResult,
98
+ diagnostics: ReturnType<TrapStore["diagnostics"]>,
99
+ candidateReview: ProjectCandidateReviewSummary | null
100
+ ): DoctorNextAction[] {
101
+ const actions: DoctorNextAction[] = [];
102
+ if (!embeddingRuntime.available) {
103
+ if (embeddingRuntime.setup_action) actions.push(embeddingRuntime.setup_action);
104
+ } else {
105
+ const projectAction = embeddingRefreshAction("project", embeddings.project);
106
+ const globalAction = embeddingRefreshAction("global", embeddings.global);
107
+ if (projectAction) actions.push(projectAction);
108
+ if (globalAction) actions.push(globalAction);
109
+ }
110
+
111
+ const stranded = diagnostics.mis_scoped_traps.global_db_project_traps.length;
112
+ if (stranded > 0) {
113
+ actions.push({
114
+ command: "codetrap repair-scope --dry-run --json",
115
+ reason: `${stranded} project-scoped trap(s) are stored in the global database.`,
116
+ });
117
+ }
118
+ if (candidateReview && candidateReview.pending_count > 0) {
119
+ if (candidateReview.next_session_id) {
120
+ actions.push({
121
+ command: `codetrap session candidates ${candidateReview.next_session_id} --json`,
122
+ reason: `${candidateReview.pending_count} pending candidate trap(s) need review.`,
123
+ });
124
+ }
125
+ actions.push({
126
+ command: "codetrap web",
127
+ reason: "Open the candidate review console.",
128
+ });
129
+ }
130
+ return actions;
131
+ }
132
+
133
+ function embeddingRefreshAction(
134
+ scope: "project" | "global",
135
+ stats: EmbeddingStateSummary | null
136
+ ): DoctorNextAction | null {
137
+ if (!stats || stats.total === 0) return null;
138
+ const needsRefresh = stats.missing + stats.stale;
139
+ if (needsRefresh === 0) return null;
140
+ const parts = [
141
+ stats.missing > 0 ? `${stats.missing} missing` : null,
142
+ stats.stale > 0 ? `${stats.stale} stale` : null,
143
+ ].filter((item): item is string => item !== null);
144
+ return {
145
+ command: `codetrap embed --scope ${scope}`,
146
+ reason: `${scope} embeddings need refresh (${parts.join(", ")}).`,
147
+ };
148
+ }
149
+
150
+ function formatNextActions(actions: DoctorNextAction[]): string[] {
151
+ if (actions.length === 0) return [" (none)"];
152
+ return actions.map((action) => ` - ${action.command} # ${action.reason}`);
153
+ }
154
+
155
+ function formatCandidateReview(summary: ProjectCandidateReviewSummary | null): string {
156
+ if (!summary) return " unavailable";
157
+ return [
158
+ ` pending=${summary.pending_count}`,
159
+ `reviewed=${summary.reviewed_count}`,
160
+ `sessions=${summary.pending_session_count}/${summary.session_count}`,
161
+ `high_quality_pending=${summary.high_quality_pending_count}`,
162
+ `needs_edit=${summary.needs_edit_count}`,
163
+ `next_session=${summary.next_session_id ?? "(none)"}`,
164
+ ].join(", ");
165
+ }
166
+
77
167
  function formatEmbeddingStats(
78
168
  label: string,
79
169
  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
+ }
@@ -19,6 +19,7 @@ export interface EmbeddingConfig {
19
19
 
20
20
  export interface StoredEmbedding {
21
21
  trap_id: number;
22
+ profile_id: string;
22
23
  provider: string;
23
24
  model: string;
24
25
  dimensions: number;
@@ -91,9 +92,119 @@ export class JinaEmbedder implements EmbeddingProvider {
91
92
  }
92
93
  }
93
94
 
94
- export function createDefaultEmbeddingProvider(): EmbeddingProvider | undefined {
95
- const apiKey = process.env.JINA_API_KEY;
96
- return apiKey ? new JinaEmbedder(apiKey) : undefined;
95
+ export type OllamaEmbedderOptions = {
96
+ endpoint?: string;
97
+ model?: string;
98
+ dimensions?: number;
99
+ fetch?: FetchLike;
100
+ };
101
+
102
+ export type OllamaProviderHealth = {
103
+ ok: boolean;
104
+ message?: string;
105
+ command?: string;
106
+ };
107
+
108
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
109
+
110
+ export const DEFAULT_OLLAMA_ENDPOINT = "http://127.0.0.1:11434";
111
+ export const DEFAULT_OLLAMA_MODEL = "qwen3-embedding:0.6b";
112
+ export const DEFAULT_OLLAMA_DIMENSIONS = 1024;
113
+
114
+ export class OllamaEmbedder implements EmbeddingProvider {
115
+ readonly provider = "ollama";
116
+ readonly model: string;
117
+ readonly dimensions: number;
118
+ private readonly endpoint: string;
119
+ private readonly fetchImpl: FetchLike;
120
+
121
+ constructor(options: OllamaEmbedderOptions = {}) {
122
+ this.endpoint = normalizeOllamaEndpoint(options.endpoint ?? DEFAULT_OLLAMA_ENDPOINT);
123
+ this.model = options.model ?? DEFAULT_OLLAMA_MODEL;
124
+ this.dimensions = options.dimensions ?? DEFAULT_OLLAMA_DIMENSIONS;
125
+ this.fetchImpl = options.fetch ?? fetch;
126
+ }
127
+
128
+ async embed(texts: string[], _task: EmbeddingTask): Promise<Float32Array[]> {
129
+ if (texts.length === 0) return [];
130
+
131
+ const response = await this.fetchImpl(`${this.endpoint}/api/embed`, {
132
+ method: "POST",
133
+ headers: {
134
+ "Content-Type": "application/json",
135
+ },
136
+ body: JSON.stringify({
137
+ model: this.model,
138
+ input: texts,
139
+ dimensions: this.dimensions,
140
+ truncate: true,
141
+ }),
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const body = await response.text();
146
+ throw new Error(`Ollama embeddings request failed (${response.status}): ${body}`);
147
+ }
148
+
149
+ const payload = (await response.json()) as {
150
+ embeddings?: number[][];
151
+ };
152
+ const rows = payload.embeddings ?? [];
153
+ if (rows.length !== texts.length) {
154
+ throw new Error(`Ollama embeddings returned ${rows.length} vectors for ${texts.length} inputs.`);
155
+ }
156
+
157
+ return rows.map((embedding) => {
158
+ if (!Array.isArray(embedding)) {
159
+ throw new Error("Ollama embeddings response is missing an embedding vector.");
160
+ }
161
+ if (embedding.length !== this.dimensions) {
162
+ throw new Error(
163
+ `Ollama embeddings returned ${embedding.length} dimensions, expected ${this.dimensions}.`
164
+ );
165
+ }
166
+ return Float32Array.from(embedding);
167
+ });
168
+ }
169
+
170
+ async health(): Promise<OllamaProviderHealth> {
171
+ try {
172
+ const response = await this.fetchImpl(`${this.endpoint}/api/tags`);
173
+ if (!response.ok) {
174
+ return {
175
+ ok: false,
176
+ message: `Ollama is not reachable at ${this.endpoint} (${response.status}).`,
177
+ command: "ollama list",
178
+ };
179
+ }
180
+
181
+ const payload = (await response.json()) as {
182
+ models?: { name?: string; model?: string }[];
183
+ };
184
+ const models = payload.models ?? [];
185
+ const installed = models.some((model) => model.name === this.model || model.model === this.model);
186
+ if (!installed) {
187
+ return {
188
+ ok: false,
189
+ message: `Ollama model ${this.model} is not installed.`,
190
+ command: `ollama pull ${this.model}`,
191
+ };
192
+ }
193
+
194
+ return { ok: true };
195
+ } catch (error) {
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ return {
198
+ ok: false,
199
+ message: `Ollama is not reachable at ${this.endpoint}: ${message}`,
200
+ command: "ollama list",
201
+ };
202
+ }
203
+ }
204
+ }
205
+
206
+ function normalizeOllamaEndpoint(endpoint: string): string {
207
+ return endpoint.replace(/\/+$/, "");
97
208
  }
98
209
 
99
210
  export function embeddingConfig(provider: EmbeddingProvider): EmbeddingConfig {
@@ -105,6 +216,10 @@ export function embeddingConfig(provider: EmbeddingProvider): EmbeddingConfig {
105
216
  };
106
217
  }
107
218
 
219
+ export function embeddingProfileId(config: EmbeddingConfig): string {
220
+ return `${config.provider}:${config.model}:${config.dimensions}:p${config.passageVersion}`;
221
+ }
222
+
108
223
  export function encodeEmbedding(embedding: Float32Array): Buffer {
109
224
  return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
110
225
  }
@@ -1,4 +1,4 @@
1
- import type { EmbeddingConfig } from "./embedder";
1
+ import { embeddingProfileId, type EmbeddingConfig } from "./embedder";
2
2
 
3
3
  export type EmbeddingStateCounts = {
4
4
  total: number;
@@ -13,6 +13,7 @@ export type EmbeddingStateSummary = EmbeddingStateCounts & {
13
13
  model: string | null;
14
14
  dimensions: number | null;
15
15
  passage_version: number | null;
16
+ profile_id: string | null;
16
17
  };
17
18
 
18
19
  export type EmbeddingStatsResult = {
@@ -33,6 +34,7 @@ export function summarizeEmbeddingState(
33
34
  model: config?.model ?? null,
34
35
  dimensions: config?.dimensions ?? null,
35
36
  passage_version: config?.passageVersion ?? null,
37
+ profile_id: config ? embeddingProfileId(config) : null,
36
38
  };
37
39
  }
38
40
 
@@ -1,6 +1,7 @@
1
1
  import type { Trap } from "../domain/trap";
2
2
  import {
3
3
  embeddingConfig,
4
+ embeddingProfileId,
4
5
  type EmbeddingConfig,
5
6
  type EmbeddingProvider,
6
7
  type StoredEmbedding,
@@ -35,6 +36,7 @@ export async function runEmbeddingJob(
35
36
  opts: EmbeddingJobOptions = {}
36
37
  ): Promise<EmbeddingJobResult> {
37
38
  const config = embeddingConfig(provider);
39
+ const profileId = embeddingProfileId(config);
38
40
  const total = adapter.countEmbeddable({ scope: opts.scope, category: opts.category });
39
41
  const traps = adapter.trapsNeedingEmbeddings(config, opts);
40
42
  if (traps.length === 0) return { generated: 0, skipped: total, batches: 0 };
@@ -53,6 +55,7 @@ export async function runEmbeddingJob(
53
55
  for (let i = 0; i < batch.length; i++) {
54
56
  adapter.saveEmbedding({
55
57
  trap_id: batch[i].id,
58
+ profile_id: profileId,
56
59
  provider: config.provider,
57
60
  model: config.model,
58
61
  dimensions: config.dimensions,