codetrap 0.1.7 → 0.1.9

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 (61) hide show
  1. package/README.md +132 -98
  2. package/docs/installation.md +61 -63
  3. package/package.json +4 -3
  4. package/plugins/codetrap-agent/.codex-plugin/plugin.json +2 -3
  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-maintainer.md +15 -0
  13. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +16 -5
  14. package/scripts/release-preflight.ts +15 -0
  15. package/scripts/search-policy-sweep.ts +131 -0
  16. package/src/commands/workflow.ts +172 -68
  17. package/src/db/embedding-queries.ts +230 -48
  18. package/src/db/queries.ts +0 -25
  19. package/src/db/repository.ts +32 -21
  20. package/src/db/schema.ts +80 -0
  21. package/src/index.ts +34 -4
  22. package/src/lib/codex-setup.ts +247 -0
  23. package/src/lib/command-requests.ts +112 -1
  24. package/src/lib/config.ts +57 -7
  25. package/src/lib/constants.ts +1 -1
  26. package/src/lib/doctor.ts +42 -12
  27. package/src/lib/embedder.ts +118 -3
  28. package/src/lib/embedding-health.ts +3 -1
  29. package/src/lib/embedding-job.ts +3 -0
  30. package/src/lib/embedding-management.ts +65 -0
  31. package/src/lib/embedding-runtime.ts +177 -0
  32. package/src/lib/output-json.ts +0 -2
  33. package/src/lib/scope-context.ts +12 -6
  34. package/src/lib/scope-migration.ts +2 -1
  35. package/src/lib/scope.ts +0 -2
  36. package/src/lib/search-eval.ts +38 -18
  37. package/src/lib/search-policy-sweep.ts +563 -0
  38. package/src/lib/search-policy.ts +0 -4
  39. package/src/lib/search-service.ts +14 -15
  40. package/src/lib/session-candidate-document.ts +175 -0
  41. package/src/lib/session-candidate-scope.ts +6 -0
  42. package/src/lib/session-capture.ts +298 -32
  43. package/src/lib/session-codec.ts +1 -8
  44. package/src/lib/session-operations.ts +83 -60
  45. package/src/lib/session-review.ts +327 -0
  46. package/src/lib/session-store.ts +87 -73
  47. package/src/lib/store.ts +74 -10
  48. package/src/lib/string-list.ts +3 -0
  49. package/src/lib/text-lines.ts +7 -0
  50. package/src/lib/trap-search-document.ts +2 -1
  51. package/src/lib/value-types.ts +3 -0
  52. package/src/web/client-review.ts +171 -0
  53. package/src/web/client-script.ts +426 -51
  54. package/src/web/client-shell.ts +414 -0
  55. package/src/web/client-text.ts +112 -0
  56. package/src/web/project-registry.ts +3 -5
  57. package/src/web/server.ts +117 -103
  58. package/src/web/static.ts +364 -19
  59. package/skills/codetrap-capture-external/SKILL.md +0 -62
  60. package/skills/codetrap-check/SKILL.md +0 -69
  61. package/src/lib/embedding-index.ts +0 -53
@@ -0,0 +1,247 @@
1
+ import {
2
+ appendFileSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { findProjectRoot } from "./scope";
14
+ import agentsTemplateAsset from "../../plugins/codetrap-agent/templates/AGENTS.codetrap.md" with { type: "text" };
15
+ import codetrapAddSkill from "../../plugins/codetrap-agent/skills/codetrap-add/SKILL.md" with { type: "text" };
16
+ import codetrapCaptureSkill from "../../plugins/codetrap-agent/skills/codetrap-capture/SKILL.md" with { type: "text" };
17
+ import codetrapCaptureExternalSkill from "../../plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md" with { type: "text" };
18
+ import codetrapCheckSkill from "../../plugins/codetrap-agent/skills/codetrap-check/SKILL.md" with { type: "text" };
19
+ import codetrapSearchSkill from "../../plugins/codetrap-agent/skills/codetrap-search/SKILL.md" with { type: "text" };
20
+
21
+ export type CodexSetupOptions = {
22
+ cwd: string;
23
+ codexHome?: string;
24
+ agentsFile?: string;
25
+ installMcp?: boolean;
26
+ skipAgents?: boolean;
27
+ dryRun?: boolean;
28
+ };
29
+
30
+ export type CodexSetupStatus =
31
+ | "already_present"
32
+ | "created"
33
+ | "appended"
34
+ | "installed"
35
+ | "updated"
36
+ | "unchanged"
37
+ | "skipped"
38
+ | "would_create"
39
+ | "would_append"
40
+ | "would_install"
41
+ | "would_update"
42
+ | "would_run"
43
+ | "failed";
44
+
45
+ export type CodexSetupResult = {
46
+ success: boolean;
47
+ project_root: string;
48
+ codex_home: string;
49
+ plugin_root: string;
50
+ dry_run: boolean;
51
+ project: {
52
+ codetrap_dir: string;
53
+ status: CodexSetupStatus;
54
+ };
55
+ skills: Array<{
56
+ name: string;
57
+ source: string;
58
+ destination: string;
59
+ status: CodexSetupStatus;
60
+ }>;
61
+ agents: {
62
+ path: string | null;
63
+ status: CodexSetupStatus;
64
+ };
65
+ mcp: {
66
+ requested: boolean;
67
+ command: string;
68
+ status: CodexSetupStatus;
69
+ exit_code?: number | null;
70
+ error?: string;
71
+ };
72
+ };
73
+
74
+ const AGENTS_TEMPLATE_PATH = "templates/AGENTS.codetrap.md";
75
+ const CODEX_MARKER = "codetrap search \"<keywords>\" --mode hybrid --json";
76
+ const MCP_COMMAND = ["codex", "mcp", "add", "codetrap", "--", "codetrap", "serve"];
77
+ const EMBEDDED_PLUGIN_ROOT = "embedded://plugins/codetrap-agent";
78
+ const EMBEDDED_SKILLS = [
79
+ { name: "codetrap-add", skill: codetrapAddSkill },
80
+ { name: "codetrap-capture", skill: codetrapCaptureSkill },
81
+ { name: "codetrap-capture-external", skill: codetrapCaptureExternalSkill },
82
+ { name: "codetrap-check", skill: codetrapCheckSkill },
83
+ { name: "codetrap-search", skill: codetrapSearchSkill },
84
+ ];
85
+
86
+ export function runCodexSetup(options: CodexSetupOptions): CodexSetupResult {
87
+ const cwd = resolve(options.cwd);
88
+ const projectRoot = findProjectRoot(cwd) ?? cwd;
89
+ const codexHome = resolveCodexHome(options.codexHome);
90
+ const pluginRoot = bundledPluginRoot();
91
+ const useEmbeddedAssets = !existsSync(pluginRoot);
92
+
93
+ const dryRun = options.dryRun === true;
94
+ const project = ensureProjectCodetrap(projectRoot, dryRun);
95
+ const skills = useEmbeddedAssets
96
+ ? installEmbeddedSkills(codexHome, dryRun)
97
+ : installSkills(pluginRoot, codexHome, dryRun);
98
+ const agents = options.skipAgents
99
+ ? { path: null, status: "skipped" as const }
100
+ : installAgentsTemplate(projectRoot, useEmbeddedAssets ? null : pluginRoot, options.agentsFile ?? "AGENTS.md", dryRun);
101
+ const mcp = setupMcp(options.installMcp === true, dryRun);
102
+ const success = mcp.status !== "failed";
103
+
104
+ return {
105
+ success,
106
+ project_root: projectRoot,
107
+ codex_home: codexHome,
108
+ plugin_root: useEmbeddedAssets ? EMBEDDED_PLUGIN_ROOT : pluginRoot,
109
+ dry_run: dryRun,
110
+ project,
111
+ skills,
112
+ agents,
113
+ mcp,
114
+ };
115
+ }
116
+
117
+ export function formatCodexSetupText(result: CodexSetupResult): string {
118
+ const installed = result.skills.filter((skill) =>
119
+ ["installed", "updated", "would_install", "would_update"].includes(skill.status)
120
+ ).length;
121
+ const unchanged = result.skills.filter((skill) => skill.status === "unchanged").length;
122
+ const lines = [
123
+ result.success ? "Codex setup complete." : "Codex setup completed with errors.",
124
+ `Project: ${result.project.status} (${result.project.codetrap_dir})`,
125
+ `Skills: ${installed} changed, ${unchanged} unchanged (${join(result.codex_home, "skills")})`,
126
+ `AGENTS: ${result.agents.status}${result.agents.path ? ` (${result.agents.path})` : ""}`,
127
+ ];
128
+ if (result.mcp.requested) {
129
+ lines.push(`MCP: ${result.mcp.status} (${result.mcp.command})`);
130
+ if (result.mcp.error) lines.push(`MCP error: ${result.mcp.error}`);
131
+ } else {
132
+ lines.push(`MCP: skipped; pass --mcp to run '${result.mcp.command}'.`);
133
+ }
134
+ if (result.dry_run) lines.unshift("Dry run; no files or Codex config were changed.");
135
+ return lines.join("\n");
136
+ }
137
+
138
+ function ensureProjectCodetrap(projectRoot: string, dryRun: boolean): CodexSetupResult["project"] {
139
+ const codetrapDir = join(projectRoot, ".codetrap");
140
+ if (existsSync(codetrapDir)) {
141
+ return { codetrap_dir: codetrapDir, status: "already_present" };
142
+ }
143
+ if (!dryRun) mkdirSync(codetrapDir, { recursive: true });
144
+ return { codetrap_dir: codetrapDir, status: dryRun ? "would_create" : "created" };
145
+ }
146
+
147
+ function installSkills(pluginRoot: string, codexHome: string, dryRun: boolean): CodexSetupResult["skills"] {
148
+ const sourceSkillsDir = join(pluginRoot, "skills");
149
+ const targetSkillsDir = join(codexHome, "skills");
150
+ if (!dryRun) mkdirSync(targetSkillsDir, { recursive: true });
151
+
152
+ return readdirSync(sourceSkillsDir, { withFileTypes: true })
153
+ .filter((entry) => entry.isDirectory())
154
+ .sort((left, right) => left.name.localeCompare(right.name))
155
+ .map((entry) => {
156
+ const source = join(sourceSkillsDir, entry.name);
157
+ const destination = join(targetSkillsDir, entry.name);
158
+ const sourceSkill = readFileSync(join(source, "SKILL.md"), "utf-8");
159
+ const destinationSkillPath = join(destination, "SKILL.md");
160
+ const exists = existsSync(destinationSkillPath);
161
+ const unchanged = exists && readFileSync(destinationSkillPath, "utf-8") === sourceSkill;
162
+ let status: CodexSetupStatus = unchanged ? "unchanged" : exists ? "updated" : "installed";
163
+ if (dryRun && status === "installed") status = "would_install";
164
+ if (dryRun && status === "updated") status = "would_update";
165
+ if (!dryRun && !unchanged) cpSync(source, destination, { recursive: true, force: true });
166
+ return { name: entry.name, source, destination, status };
167
+ });
168
+ }
169
+
170
+ function installEmbeddedSkills(codexHome: string, dryRun: boolean): CodexSetupResult["skills"] {
171
+ const targetSkillsDir = join(codexHome, "skills");
172
+ if (!dryRun) mkdirSync(targetSkillsDir, { recursive: true });
173
+
174
+ return EMBEDDED_SKILLS.map((entry) => {
175
+ const destination = join(targetSkillsDir, entry.name);
176
+ const destinationSkillPath = join(destination, "SKILL.md");
177
+ const exists = existsSync(destinationSkillPath);
178
+ const unchanged = exists && readFileSync(destinationSkillPath, "utf-8") === entry.skill;
179
+ let status: CodexSetupStatus = unchanged ? "unchanged" : exists ? "updated" : "installed";
180
+ if (dryRun && status === "installed") status = "would_install";
181
+ if (dryRun && status === "updated") status = "would_update";
182
+ if (!dryRun && !unchanged) {
183
+ mkdirSync(destination, { recursive: true });
184
+ writeFileSync(destinationSkillPath, entry.skill);
185
+ }
186
+ return {
187
+ name: entry.name,
188
+ source: `${EMBEDDED_PLUGIN_ROOT}/skills/${entry.name}`,
189
+ destination,
190
+ status,
191
+ };
192
+ });
193
+ }
194
+
195
+ function installAgentsTemplate(
196
+ projectRoot: string,
197
+ pluginRoot: string | null,
198
+ agentsFile: string,
199
+ dryRun: boolean
200
+ ): CodexSetupResult["agents"] {
201
+ const target = resolve(projectRoot, agentsFile);
202
+ const template = (pluginRoot
203
+ ? readFileSync(join(pluginRoot, AGENTS_TEMPLATE_PATH), "utf-8")
204
+ : agentsTemplateAsset
205
+ ).trimEnd();
206
+ if (existsSync(target)) {
207
+ const current = readFileSync(target, "utf-8");
208
+ if (current.includes(CODEX_MARKER)) {
209
+ return { path: target, status: "already_present" };
210
+ }
211
+ if (!dryRun) appendFileSync(target, `${current.endsWith("\n") ? "\n" : "\n\n"}${template}\n`);
212
+ return { path: target, status: dryRun ? "would_append" : "appended" };
213
+ }
214
+ if (!dryRun) writeFileSync(target, `${template}\n`);
215
+ return { path: target, status: dryRun ? "would_create" : "created" };
216
+ }
217
+
218
+ function setupMcp(requested: boolean, dryRun: boolean): CodexSetupResult["mcp"] {
219
+ const command = MCP_COMMAND.join(" ");
220
+ if (!requested) return { requested, command, status: "skipped" };
221
+ if (dryRun) return { requested, command, status: "would_run" };
222
+
223
+ const result = Bun.spawnSync({
224
+ cmd: MCP_COMMAND,
225
+ stdout: "pipe",
226
+ stderr: "pipe",
227
+ });
228
+ if (result.success) return { requested, command, status: "installed", exit_code: result.exitCode };
229
+
230
+ const stderr = new TextDecoder().decode(result.stderr).trim();
231
+ const stdout = new TextDecoder().decode(result.stdout).trim();
232
+ return {
233
+ requested,
234
+ command,
235
+ status: "failed",
236
+ exit_code: result.exitCode,
237
+ error: stderr || stdout || "codex mcp add failed",
238
+ };
239
+ }
240
+
241
+ function resolveCodexHome(codexHome?: string): string {
242
+ return resolve(codexHome ?? process.env.CODEX_HOME ?? join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), ".codex"));
243
+ }
244
+
245
+ function bundledPluginRoot(): string {
246
+ return join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), "plugins", "codetrap-agent");
247
+ }
@@ -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";
package/src/lib/doctor.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { TrapStore } from "./store";
2
2
  import type { TrapOperations } from "./trap-operations";
3
+ import type { EmbeddingRuntimeStatus } from "./embedding-runtime";
3
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";
6
8
 
7
9
  export type DoctorNextAction = {
8
10
  command: string;
@@ -28,19 +30,22 @@ export type DoctorReport = {
28
30
  global_db_project_traps: ReturnType<TrapStore["diagnostics"]>["mis_scoped_traps"]["global_db_project_traps"];
29
31
  };
30
32
  };
33
+ candidate_review: ProjectCandidateReviewSummary | null;
31
34
  next_actions: DoctorNextAction[];
32
35
  mcp_hint: string;
33
36
  };
34
37
 
35
- export function buildDoctorReport(
38
+ export async function buildDoctorReport(
36
39
  store: TrapStore,
37
40
  operations: TrapOperations,
38
- cwd = process.cwd()
39
- ): DoctorReport {
41
+ cwd = process.cwd(),
42
+ candidateReview: ProjectCandidateReviewSummary | null = null
43
+ ): Promise<DoctorReport> {
40
44
  const scope = createScopeContext(cwd);
41
45
  const stats = operations.getStats();
42
46
  const embeddings = operations.getEmbeddingStats();
43
- const semanticAvailable = store.hasEmbeddingProvider();
47
+ const embeddingRuntime = await store.embeddingRuntimeHealth();
48
+ const semanticAvailable = embeddingRuntime.available;
44
49
  const diagnostics = store.diagnostics();
45
50
 
46
51
  return {
@@ -57,7 +62,8 @@ export function buildDoctorReport(
57
62
  diagnostics: {
58
63
  mis_scoped_traps: diagnostics.mis_scoped_traps,
59
64
  },
60
- next_actions: buildDoctorNextActions(semanticAvailable, embeddings, diagnostics),
65
+ candidate_review: candidateReview,
66
+ next_actions: buildDoctorNextActions(embeddingRuntime, embeddings, diagnostics, candidateReview),
61
67
  mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
62
68
  };
63
69
  }
@@ -78,6 +84,8 @@ export function formatDoctorText(report: DoctorReport): string {
78
84
  ` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
79
85
  "Diagnostics:",
80
86
  ` global_db_project_traps: ${report.diagnostics.mis_scoped_traps.global_db_project_traps.length}`,
87
+ "Candidate review:",
88
+ formatCandidateReview(report.candidate_review),
81
89
  "Next actions:",
82
90
  ...formatNextActions(report.next_actions),
83
91
  `mcp_hint: ${report.mcp_hint}`,
@@ -85,16 +93,14 @@ export function formatDoctorText(report: DoctorReport): string {
85
93
  }
86
94
 
87
95
  function buildDoctorNextActions(
88
- semanticAvailable: boolean,
96
+ embeddingRuntime: EmbeddingRuntimeStatus,
89
97
  embeddings: EmbeddingStatsResult,
90
- diagnostics: ReturnType<TrapStore["diagnostics"]>
98
+ diagnostics: ReturnType<TrapStore["diagnostics"]>,
99
+ candidateReview: ProjectCandidateReviewSummary | null
91
100
  ): DoctorNextAction[] {
92
101
  const actions: DoctorNextAction[] = [];
93
- if (!semanticAvailable) {
94
- actions.push({
95
- command: "export JINA_API_KEY=<your-jina-api-key>",
96
- reason: "Enable semantic and hybrid search; otherwise use --mode fts.",
97
- });
102
+ if (!embeddingRuntime.available) {
103
+ if (embeddingRuntime.setup_action) actions.push(embeddingRuntime.setup_action);
98
104
  } else {
99
105
  const projectAction = embeddingRefreshAction("project", embeddings.project);
100
106
  const globalAction = embeddingRefreshAction("global", embeddings.global);
@@ -109,6 +115,18 @@ function buildDoctorNextActions(
109
115
  reason: `${stranded} project-scoped trap(s) are stored in the global database.`,
110
116
  });
111
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
+ }
112
130
  return actions;
113
131
  }
114
132
 
@@ -134,6 +152,18 @@ function formatNextActions(actions: DoctorNextAction[]): string[] {
134
152
  return actions.map((action) => ` - ${action.command} # ${action.reason}`);
135
153
  }
136
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
+
137
167
  function formatEmbeddingStats(
138
168
  label: string,
139
169
  stats: EmbeddingStatsResult["global"] | null