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.
- package/README.md +159 -51
- package/docs/installation.md +113 -29
- package/package.json +4 -3
- package/plugins/codetrap-agent/.codex-plugin/plugin.json +1 -2
- package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +19 -17
- package/plugins/codetrap-agent/hooks.json +2 -2
- package/{skills → plugins/codetrap-agent/skills}/codetrap-add/SKILL.md +10 -4
- package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +14 -3
- package/plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md +52 -9
- package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +74 -6
- package/{skills → plugins/codetrap-agent/skills}/codetrap-search/SKILL.md +6 -5
- package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +31 -5
- package/scripts/search-policy-sweep.ts +131 -0
- package/src/commands/workflow.ts +186 -68
- package/src/db/connection.ts +6 -6
- package/src/db/embedding-queries.ts +230 -48
- package/src/db/queries.ts +0 -25
- package/src/db/repository.ts +32 -21
- package/src/db/schema.ts +80 -0
- package/src/index.ts +32 -7
- package/src/lib/command-requests.ts +134 -1
- package/src/lib/config.ts +57 -7
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor.ts +96 -6
- package/src/lib/embed-output.ts +26 -0
- package/src/lib/embedder.ts +118 -3
- package/src/lib/embedding-health.ts +3 -1
- package/src/lib/embedding-job.ts +3 -0
- package/src/lib/embedding-management.ts +65 -0
- package/src/lib/embedding-runtime.ts +177 -0
- package/src/lib/output-json.ts +0 -2
- package/src/lib/scope-context.ts +17 -11
- package/src/lib/scope-migration.ts +2 -1
- package/src/lib/scope.ts +4 -6
- package/src/lib/search-eval.ts +136 -23
- package/src/lib/search-policy-sweep.ts +563 -0
- package/src/lib/search-policy.ts +0 -4
- package/src/lib/search-service.ts +14 -15
- package/src/lib/session-candidate-document.ts +175 -0
- package/src/lib/session-candidate-scope.ts +6 -0
- package/src/lib/session-capture.ts +298 -32
- package/src/lib/session-codec.ts +1 -8
- package/src/lib/session-operations.ts +111 -51
- package/src/lib/session-review.ts +327 -0
- package/src/lib/session-store.ts +177 -55
- package/src/lib/store.ts +79 -11
- package/src/lib/string-list.ts +3 -0
- package/src/lib/text-lines.ts +7 -0
- package/src/lib/trap-search-document.ts +2 -1
- package/src/lib/value-types.ts +3 -0
- package/src/web/client-review.ts +171 -0
- package/src/web/client-script.ts +1543 -0
- package/src/web/client-shell.ts +414 -0
- package/src/web/client-text.ts +447 -0
- package/src/web/project-registry.ts +3 -5
- package/src/web/server.ts +184 -111
- package/src/web/static.ts +581 -484
- package/skills/codetrap-capture-external/SKILL.md +0 -62
- package/skills/codetrap-check/SKILL.md +0 -69
- 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 =
|
|
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
|
-
|
|
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
|
-
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/lib/embedder.ts
CHANGED
|
@@ -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
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
package/src/lib/embedding-job.ts
CHANGED
|
@@ -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,
|