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
@@ -1,4 +1,6 @@
1
1
  import { WEB_TEXT_JSON } from "./client-text";
2
+ import { WEB_REVIEW_CLIENT_SCRIPT } from "./client-review";
3
+ import { WEB_SHELL_CLIENT_SCRIPT } from "./client-shell";
2
4
 
3
5
  export function webClientScript(textJson = WEB_TEXT_JSON): string {
4
6
  return ` const qs = new URLSearchParams(location.search);
@@ -6,6 +8,13 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
6
8
  if (token) sessionStorage.setItem("codetrap-token", token);
7
9
  const savedLocale = localStorage.getItem("codetrap-locale");
8
10
  const initialLocale = savedLocale === "zh" ? "zh" : "en";
11
+ const savedSidebarCollapsed = localStorage.getItem("codetrap-sidebar-collapsed") === "true";
12
+ const savedQueueCollapsed = localStorage.getItem("codetrap-queue-collapsed") === "true";
13
+ const EMBEDDING_DEFAULTS = {
14
+ endpoint: "http://127.0.0.1:11434",
15
+ model: "qwen3-embedding:0.6b",
16
+ dimensions: "1024"
17
+ };
9
18
 
10
19
  const TEXT = ${textJson};
11
20
 
@@ -14,6 +23,7 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
14
23
  mainView: "review",
15
24
  projects: [],
16
25
  sessions: [],
26
+ candidateReview: null,
17
27
  candidates: [],
18
28
  traps: [],
19
29
  trapKey: null,
@@ -24,16 +34,25 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
24
34
  trapSort: "updated",
25
35
  insightTraps: [],
26
36
  insightFilters: { scope: "", status: "all" },
37
+ embeddingStatus: null,
38
+ embeddingSettings: null,
39
+ embeddingProviderDraft: "ollama",
40
+ embeddingOllama: { ...EMBEDDING_DEFAULTS },
41
+ embeddingReindexing: null,
27
42
  projectRoot: null,
28
43
  sessionId: null,
29
44
  candidateId: null,
30
45
  candidateView: "inbox",
46
+ candidateDirty: false,
47
+ sidebarCollapsed: savedSidebarCollapsed,
48
+ queueCollapsed: savedQueueCollapsed,
31
49
  options: { categories: [], severities: [], scopes: [] },
32
50
  conflicts: []
33
51
  };
34
52
 
35
53
  const el = (id) => document.getElementById(id);
36
-
54
+ ${WEB_SHELL_CLIENT_SCRIPT}
55
+ ${WEB_REVIEW_CLIENT_SCRIPT}
37
56
  function t(key, params = {}) {
38
57
  const text = TEXT[state.locale]?.[key] ?? TEXT.en[key] ?? key;
39
58
  return Object.entries(params).reduce((value, [name, replacement]) =>
@@ -62,9 +81,11 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
62
81
  document.querySelector("[data-main-view='review']").textContent = t("nav.review");
63
82
  document.querySelector("[data-main-view='library']").textContent = t("nav.library");
64
83
  document.querySelector("[data-main-view='insights']").textContent = t("nav.insights");
84
+ document.querySelector("[data-main-view='embeddings']").textContent = t("nav.embeddings");
65
85
  document.querySelectorAll("[data-locale]").forEach((button) => {
66
86
  button.classList.toggle("active", button.dataset.locale === state.locale);
67
87
  });
88
+ renderSidebarToggle();
68
89
  }
69
90
 
70
91
  function setLocale(locale) {
@@ -113,23 +134,27 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
113
134
  async function loadSessions() {
114
135
  if (!state.projectRoot) {
115
136
  state.sessions = [];
137
+ state.candidateReview = null;
116
138
  state.candidates = [];
117
139
  state.traps = [];
118
140
  state.insightTraps = [];
141
+ state.embeddingStatus = null;
142
+ state.embeddingSettings = null;
119
143
  renderSessions();
120
144
  renderActiveView();
121
145
  return;
122
146
  }
123
147
  const data = await api("/api/sessions?project=" + encodeURIComponent(state.projectRoot));
124
148
  state.sessions = data.sessions;
125
- if (!state.sessionId || !state.sessions.some((s) => s.id === state.sessionId)) {
126
- state.sessionId = state.sessions[0]?.id || null;
127
- }
149
+ state.candidateReview = data.candidate_review || null;
150
+ state.sessionId = selectedReviewSessionId(state.sessions, state.sessionId);
128
151
  renderSessions();
129
152
  if (state.mainView === "library") {
130
153
  await loadTraps();
131
154
  } else if (state.mainView === "insights") {
132
155
  await loadInsightTraps();
156
+ } else if (state.mainView === "embeddings") {
157
+ await loadEmbeddings();
133
158
  } else {
134
159
  await loadCandidates();
135
160
  }
@@ -146,7 +171,12 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
146
171
  }
147
172
  const data = await api("/api/candidates?project=" + encodeURIComponent(state.projectRoot) + "&session=" + encodeURIComponent(state.sessionId));
148
173
  state.candidates = data.candidates;
149
- selectVisibleCandidate();
174
+ state.candidateId = reviewQueueModel({
175
+ candidates: state.candidates,
176
+ candidateView: state.candidateView,
177
+ candidateId: state.candidateId,
178
+ candidateReview: state.candidateReview
179
+ }).selectedCandidateId;
150
180
  if (state.mainView === "review") {
151
181
  renderCandidates();
152
182
  renderDetail();
@@ -198,6 +228,26 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
198
228
  }
199
229
  }
200
230
 
231
+ async function loadEmbeddings() {
232
+ if (!state.projectRoot) {
233
+ state.embeddingStatus = null;
234
+ state.embeddingSettings = null;
235
+ if (state.mainView === "embeddings") {
236
+ renderEmbeddingsView();
237
+ renderEmbeddingsDetail();
238
+ }
239
+ return;
240
+ }
241
+ const data = await api("/api/embeddings?project=" + encodeURIComponent(state.projectRoot));
242
+ state.embeddingStatus = data;
243
+ state.embeddingSettings = data.settings || null;
244
+ syncEmbeddingDraftFromStatus(data);
245
+ if (state.mainView === "embeddings") {
246
+ renderEmbeddingsView();
247
+ renderEmbeddingsDetail();
248
+ }
249
+ }
250
+
201
251
  function renderMainViewButtons() {
202
252
  document.querySelectorAll("[data-main-view]").forEach((button) => {
203
253
  button.classList.toggle("active", button.dataset.mainView === state.mainView);
@@ -210,14 +260,23 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
210
260
  el("queue-title").textContent = t("title.trapLibrary");
211
261
  el("detail-title").textContent = t("title.trapDetail");
212
262
  el("candidate-tabs").classList.add("hidden");
263
+ hideReviewSummary();
213
264
  renderLibrary();
214
265
  renderTrapDetail();
215
266
  } else if (state.mainView === "insights") {
216
267
  el("queue-title").textContent = t("title.growthInsights");
217
268
  el("detail-title").textContent = t("title.insightDetail");
218
269
  el("candidate-tabs").classList.add("hidden");
270
+ hideReviewSummary();
219
271
  renderInsightsView();
220
272
  renderInsightDetail();
273
+ } else if (state.mainView === "embeddings") {
274
+ el("queue-title").textContent = t("title.embeddings");
275
+ el("detail-title").textContent = t("title.embeddingDetail");
276
+ el("candidate-tabs").classList.add("hidden");
277
+ hideReviewSummary();
278
+ renderEmbeddingsView();
279
+ renderEmbeddingsDetail();
221
280
  } else {
222
281
  el("queue-title").textContent = t("title.candidateInbox");
223
282
  el("detail-title").textContent = t("title.candidateDetail");
@@ -242,6 +301,8 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
242
301
  state.trapKey = null;
243
302
  state.trapDetails = {};
244
303
  state.insightTraps = [];
304
+ state.embeddingStatus = null;
305
+ state.embeddingSettings = null;
245
306
  renderProjects();
246
307
  await loadSessions();
247
308
  });
@@ -255,6 +316,7 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
255
316
  <span class="row-title">\${escapeHtml(session.goal)}</span>
256
317
  <span class="meta">
257
318
  <span class="pill">\${escapeHtml(valueLabel(session.status))}</span>
319
+ <span class="pill \${session.pending_count ? "warn" : ""}">\${escapeHtml(t("pill.pending", { count: session.pending_count || 0 }))}</span>
258
320
  <span class="pill">\${escapeHtml(t("pill.candidates", { count: session.candidate_count || 0 }))}</span>
259
321
  <span class="pill accepted">\${escapeHtml(t("pill.accepted", { count: session.accepted_count || 0 }))}</span>
260
322
  </span>
@@ -315,14 +377,21 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
315
377
 
316
378
  function renderCandidates() {
317
379
  if (state.mainView !== "review") return;
318
- const pendingCount = state.candidates.filter((candidate) => candidate.status === "proposed").length;
319
- const reviewedCount = state.candidates.length - pendingCount;
320
- const sorted = sortedVisibleCandidates();
321
- selectVisibleCandidate(sorted);
380
+ const model = reviewQueueModel({
381
+ candidates: state.candidates,
382
+ candidateView: state.candidateView,
383
+ candidateId: state.candidateId,
384
+ candidateReview: state.candidateReview
385
+ });
386
+ const pendingCount = model.pendingCount;
387
+ const reviewedCount = model.reviewedCount;
388
+ const sorted = model.visibleCandidates;
389
+ state.candidateId = model.selectedCandidateId;
322
390
  const session = state.sessions.find((item) => item.id === state.sessionId);
323
391
  el("queue-meta").textContent = session
324
392
  ? t("meta.sessionCounts", { goal: session.goal, pending: pendingCount, reviewed: reviewedCount })
325
393
  : t("meta.noSession");
394
+ renderReviewSummary(model.summary);
326
395
  renderCandidateViewTabs(pendingCount, reviewedCount);
327
396
  el("candidates").innerHTML = sorted.length ? sorted.map((candidate) => \`
328
397
  <div class="row \${candidate.id === state.candidateId ? "active" : ""} \${candidate.status} \${reviewCssClass(candidate)}">
@@ -596,6 +665,267 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
596
665
  \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noTraps")) + '</div>';
597
666
  }
598
667
 
668
+ function syncEmbeddingDraftFromStatus(status) {
669
+ const settings = status?.settings || null;
670
+ const runtime = status?.runtime || null;
671
+ const provider = settings?.provider || runtime?.provider || "ollama";
672
+ state.embeddingProviderDraft = provider === "jina" ? "jina" : "ollama";
673
+ if (state.embeddingProviderDraft === "ollama") {
674
+ state.embeddingOllama = {
675
+ endpoint: settings?.endpoint || EMBEDDING_DEFAULTS.endpoint,
676
+ model: settings?.model || runtime?.model || EMBEDDING_DEFAULTS.model,
677
+ dimensions: String(settings?.dimensions || runtime?.dimensions || EMBEDDING_DEFAULTS.dimensions)
678
+ };
679
+ }
680
+ }
681
+
682
+ function renderEmbeddingsView() {
683
+ if (state.mainView !== "embeddings") return;
684
+ const status = state.embeddingStatus;
685
+ const runtime = status?.runtime || null;
686
+ const project = status?.project || null;
687
+ const global = status?.global || null;
688
+ const provider = runtime?.provider || state.embeddingProviderDraft || "";
689
+ const providerLabel = provider ? valueLabel(provider) : t("embedding.notConfigured");
690
+ el("queue-title").textContent = t("title.embeddings");
691
+ el("candidate-tabs").classList.add("hidden");
692
+ el("queue-meta").textContent = state.projectRoot && status
693
+ ? t("meta.embeddingCounts", {
694
+ provider: providerLabel,
695
+ projectFresh: project?.fresh ?? 0,
696
+ projectTotal: project?.total ?? 0,
697
+ globalFresh: global?.fresh ?? 0,
698
+ globalTotal: global?.total ?? 0
699
+ })
700
+ : t(state.projectRoot ? "embedding.notConfigured" : "meta.noProject");
701
+
702
+ if (!state.projectRoot) {
703
+ el("candidates").innerHTML = '<div class="empty">' + escapeHtml(t("meta.selectProject")) + '</div>';
704
+ return;
705
+ }
706
+
707
+ el("candidates").innerHTML = \`
708
+ <div class="summary-grid">
709
+ \${metric(t("metric.activeProvider"), providerLabel, runtimeStateLabel(runtime))}
710
+ \${metric(t("metric.activeProfile"), shortProfileId(runtime?.profile_id), runtime?.profile_id || t("embedding.noProfile"))}
711
+ \${metric(t("metric.projectFresh"), embeddingFreshValue(project), embeddingNeedsReindex(project))}
712
+ \${metric(t("metric.globalFresh"), embeddingFreshValue(global), embeddingNeedsReindex(global))}
713
+ </div>
714
+ <form class="settings-form" id="embedding-form">
715
+ <div class="status-line">
716
+ <span class="status-dot \${runtime?.available ? "available" : "unavailable"}" aria-hidden="true"></span>
717
+ <span class="pill \${runtime?.available ? "accepted" : "warn"}">\${escapeHtml(runtimeStateLabel(runtime))}</span>
718
+ \${runtime?.profile_id ? '<span class="pill scope">' + escapeHtml(t("embedding.activeProfile")) + '</span>' : ''}
719
+ </div>
720
+ <div class="segmented" id="embedding-provider-tabs" aria-label="\${escapeAttr(t("label.provider"))}">
721
+ <button type="button" data-embedding-provider="ollama" class="\${state.embeddingProviderDraft === "ollama" ? "active" : ""}">\${escapeHtml(valueLabel("ollama"))}</button>
722
+ <button type="button" data-embedding-provider="jina" class="\${state.embeddingProviderDraft === "jina" ? "active" : ""}">\${escapeHtml(valueLabel("jina"))}</button>
723
+ </div>
724
+ <div class="provider-fields \${state.embeddingProviderDraft === "ollama" ? "" : "hidden"}">
725
+ <div class="field"><label for="embedding-endpoint">\${escapeHtml(t("label.endpoint"))}</label><input id="embedding-endpoint" value="\${escapeAttr(state.embeddingOllama.endpoint)}" placeholder="\${escapeAttr(t("placeholder.endpoint"))}"></div>
726
+ <div class="field"><label for="embedding-model">\${escapeHtml(t("label.model"))}</label><input id="embedding-model" value="\${escapeAttr(state.embeddingOllama.model)}" placeholder="\${escapeAttr(t("placeholder.model"))}"></div>
727
+ <div class="field"><label for="embedding-dimensions">\${escapeHtml(t("label.dimensions"))}</label><input id="embedding-dimensions" type="number" min="1" step="1" value="\${escapeAttr(state.embeddingOllama.dimensions)}"></div>
728
+ </div>
729
+ <div class="warning \${state.embeddingProviderDraft === "jina" ? "" : "hidden"}">\${escapeHtml(t("hint.jinaEnv"))}</div>
730
+ <button type="submit" class="primary">\${escapeHtml(t("action.useProvider"))}</button>
731
+ </form>
732
+ <div class="section">
733
+ <div class="title">\${escapeHtml(t("title.reindex"))}</div>
734
+ <div class="subtle">\${escapeHtml(t("hint.reindexAfterSwitch"))}</div>
735
+ <div class="actions" style="padding:0;border-top:0;background:transparent">
736
+ <button type="button" id="embedding-reindex-project" \${state.embeddingReindexing ? "disabled" : ""}>\${escapeHtml(t("action.reindexProject"))}</button>
737
+ <button type="button" id="embedding-reindex-global" \${state.embeddingReindexing ? "disabled" : ""}>\${escapeHtml(t("action.reindexGlobal"))}</button>
738
+ </div>
739
+ </div>
740
+ \`;
741
+ bindEmbeddingsControls();
742
+ }
743
+
744
+ function renderEmbeddingsDetail() {
745
+ if (state.mainView !== "embeddings") return;
746
+ const status = state.embeddingStatus;
747
+ const runtime = status?.runtime || null;
748
+ el("detail-title").textContent = t("title.embeddingDetail");
749
+ el("detail-meta").textContent = state.projectRoot
750
+ ? t("meta.embeddingDetail", {
751
+ profile: runtime?.profile_id || t("embedding.noProfile"),
752
+ state: runtimeStateLabel(runtime)
753
+ })
754
+ : t("meta.selectProject");
755
+
756
+ if (!state.projectRoot) {
757
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("meta.selectProject")) + '</div>';
758
+ return;
759
+ }
760
+ if (!status) {
761
+ el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noData")) + '</div>';
762
+ return;
763
+ }
764
+
765
+ el("detail").innerHTML = \`
766
+ <div class="scroll">
767
+ <div class="section">
768
+ <div class="title">\${escapeHtml(t("title.currentProfile"))}</div>
769
+ <div class="detail-kv">
770
+ \${kv(t("label.provider"), runtime?.provider ? valueLabel(runtime.provider) : t("embedding.notConfigured"))}
771
+ \${kv(t("label.model"), runtime?.model || "-")}
772
+ \${kv(t("label.dimensions"), runtime?.dimensions ?? "-")}
773
+ \${kv(t("label.profileId"), runtime?.profile_id || t("embedding.noProfile"))}
774
+ \${kv(t("label.available"), runtimeStateLabel(runtime))}
775
+ \${kv(t("label.setupAction"), runtime?.setup_action?.command || "-")}
776
+ </div>
777
+ \${runtime?.setup_action ? '<div class="warning">' + escapeHtml(runtime.setup_action.reason) + '</div>' : ''}
778
+ </div>
779
+ \${renderEmbeddingScopeDetail("project", status.project)}
780
+ \${renderEmbeddingScopeDetail("global", status.global)}
781
+ </div>
782
+ \`;
783
+ }
784
+
785
+ function bindEmbeddingsControls() {
786
+ document.querySelectorAll("[data-embedding-provider]").forEach((button) => {
787
+ button.addEventListener("click", () => {
788
+ state.embeddingProviderDraft = button.dataset.embeddingProvider === "jina" ? "jina" : "ollama";
789
+ renderEmbeddingsView();
790
+ });
791
+ });
792
+ const endpoint = el("embedding-endpoint");
793
+ if (endpoint) endpoint.addEventListener("input", () => state.embeddingOllama.endpoint = endpoint.value);
794
+ const model = el("embedding-model");
795
+ if (model) model.addEventListener("input", () => state.embeddingOllama.model = model.value);
796
+ const dimensions = el("embedding-dimensions");
797
+ if (dimensions) dimensions.addEventListener("input", () => state.embeddingOllama.dimensions = dimensions.value);
798
+ const form = el("embedding-form");
799
+ if (form) {
800
+ form.addEventListener("submit", async (event) => {
801
+ event.preventDefault();
802
+ await useEmbeddingProvider();
803
+ });
804
+ }
805
+ const project = el("embedding-reindex-project");
806
+ if (project) project.addEventListener("click", () => reindexEmbeddings("project"));
807
+ const global = el("embedding-reindex-global");
808
+ if (global) global.addEventListener("click", () => reindexEmbeddings("global"));
809
+ }
810
+
811
+ async function useEmbeddingProvider() {
812
+ if (!state.projectRoot) return;
813
+ const body = {
814
+ projectRoot: state.projectRoot,
815
+ provider: state.embeddingProviderDraft
816
+ };
817
+ if (state.embeddingProviderDraft === "ollama") {
818
+ const dimensions = Number.parseInt(state.embeddingOllama.dimensions, 10);
819
+ if (!Number.isInteger(dimensions) || dimensions <= 0) {
820
+ showStatus(t("status.invalidDimensions"), true);
821
+ return;
822
+ }
823
+ body.endpoint = state.embeddingOllama.endpoint || EMBEDDING_DEFAULTS.endpoint;
824
+ body.model = state.embeddingOllama.model || EMBEDDING_DEFAULTS.model;
825
+ body.dimensions = dimensions;
826
+ }
827
+ try {
828
+ const data = await api("/api/embeddings/use", {
829
+ method: "POST",
830
+ body: JSON.stringify(body)
831
+ });
832
+ state.embeddingSettings = data.settings || data.embeddings || null;
833
+ state.embeddingStatus = {
834
+ project_root: data.project_root,
835
+ settings: state.embeddingSettings,
836
+ ...data.status
837
+ };
838
+ syncEmbeddingDraftFromStatus(state.embeddingStatus);
839
+ renderEmbeddingsView();
840
+ renderEmbeddingsDetail();
841
+ showStatus(t("status.embeddingProviderSaved"));
842
+ } catch (error) {
843
+ showStatus(error.message, true);
844
+ }
845
+ }
846
+
847
+ async function reindexEmbeddings(scope) {
848
+ if (!state.projectRoot || state.embeddingReindexing) return;
849
+ state.embeddingReindexing = scope;
850
+ renderEmbeddingsView();
851
+ try {
852
+ const data = await api("/api/embeddings/reindex", {
853
+ method: "POST",
854
+ body: JSON.stringify({ projectRoot: state.projectRoot, scope })
855
+ });
856
+ state.embeddingStatus = {
857
+ project_root: data.project_root,
858
+ settings: state.embeddingSettings,
859
+ ...data.status
860
+ };
861
+ renderEmbeddingsView();
862
+ renderEmbeddingsDetail();
863
+ showStatus(t("status.embeddingsReindexed", {
864
+ generated: data.result?.generated ?? 0,
865
+ skipped: data.result?.skipped ?? 0
866
+ }));
867
+ } catch (error) {
868
+ showStatus(error.message, true);
869
+ } finally {
870
+ state.embeddingReindexing = null;
871
+ renderEmbeddingsView();
872
+ }
873
+ }
874
+
875
+ function renderEmbeddingScopeDetail(scope, status) {
876
+ const title = scope === "project" ? t("title.projectEmbeddings") : t("title.globalEmbeddings");
877
+ if (!status) {
878
+ return \`<div class="section"><div class="title">\${escapeHtml(title)}</div><div class="empty">\${escapeHtml(t("empty.noData"))}</div></div>\`;
879
+ }
880
+ return \`<div class="section">
881
+ <div class="title">\${escapeHtml(title)}</div>
882
+ <div class="detail-kv">
883
+ \${kv(t("label.total"), status.total)}
884
+ \${kv(t("label.fresh"), status.fresh)}
885
+ \${kv(t("label.stale"), status.stale)}
886
+ \${kv(t("label.missing"), status.missing)}
887
+ </div>
888
+ <div class="title">\${escapeHtml(t("title.storedProfiles"))}</div>
889
+ <div class="profile-list">\${renderEmbeddingProfiles(status.profiles || [])}</div>
890
+ </div>\`;
891
+ }
892
+
893
+ function renderEmbeddingProfiles(profiles) {
894
+ return profiles.length ? profiles.map((profile) => \`
895
+ <div class="profile-row">
896
+ <div class="row-title">\${escapeHtml(profile.id)}</div>
897
+ <div class="meta">
898
+ <span class="pill">\${escapeHtml(valueLabel(profile.provider))}</span>
899
+ <span class="pill">\${escapeHtml(profile.model)}</span>
900
+ <span class="pill">\${escapeHtml(String(profile.dimensions))}d</span>
901
+ <span class="pill">\${escapeHtml(t("label.count"))}: \${escapeHtml(profile.embedding_count)}</span>
902
+ </div>
903
+ <div class="subtle">\${escapeHtml(profile.updated_at || "-")}</div>
904
+ </div>
905
+ \`).join("") : '<div class="empty">' + escapeHtml(t("empty.noProfiles")) + '</div>';
906
+ }
907
+
908
+ function runtimeStateLabel(runtime) {
909
+ if (!runtime?.provider) return t("embedding.notConfigured");
910
+ return runtime.available ? t("embedding.available") : t("embedding.unavailable");
911
+ }
912
+
913
+ function embeddingFreshValue(status) {
914
+ if (!status) return "-";
915
+ return String(status.fresh || 0) + "/" + String(status.total || 0);
916
+ }
917
+
918
+ function embeddingNeedsReindex(status) {
919
+ if (!status || status.total === 0) return t("embedding.noProfile");
920
+ return status.fresh === status.total ? t("embedding.activeProfile") : t("embedding.profileNeedsReindex");
921
+ }
922
+
923
+ function shortProfileId(profileId) {
924
+ if (!profileId) return "-";
925
+ const parts = profileId.split(":");
926
+ return parts.length >= 4 ? parts[0] + " / " + parts[1] : profileId;
927
+ }
928
+
599
929
  function metric(label, value, detail) {
600
930
  return \`<div class="metric"><div class="metric-label">\${escapeHtml(label)}</div><div class="metric-value">\${escapeHtml(value)}</div><div class="subtle">\${escapeHtml(detail)}</div></div>\`;
601
931
  }
@@ -735,20 +1065,29 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
735
1065
  });
736
1066
  }
737
1067
 
738
- function sortedVisibleCandidates() {
739
- return state.candidates
740
- .filter(candidateVisible)
741
- .sort((a, b) => statusRank(a.status) - statusRank(b.status) || b.quality_score - a.quality_score);
742
- }
743
-
744
- function candidateVisible(candidate) {
745
- return state.candidateView === "inbox" ? candidate.status === "proposed" : candidate.status !== "proposed";
1068
+ function renderReviewSummary(summary = visibleReviewSummary(state.candidateReview)) {
1069
+ const target = el("review-summary");
1070
+ if (!target) return;
1071
+ if (!summary) {
1072
+ target.classList.add("hidden");
1073
+ target.innerHTML = "";
1074
+ return;
1075
+ }
1076
+ target.classList.remove("hidden");
1077
+ target.innerHTML = \`
1078
+ <div class="review-banner">
1079
+ <strong>\${escapeHtml(t("reviewSummary.pending", { count: summary.pending_count }))}</strong>
1080
+ <span>\${escapeHtml(t("reviewSummary.sessions", { count: summary.pending_session_count }))}</span>
1081
+ <span>\${escapeHtml(t("reviewSummary.quality", { high: summary.high_quality_pending_count, edit: summary.needs_edit_count }))}</span>
1082
+ </div>
1083
+ \`;
746
1084
  }
747
1085
 
748
- function selectVisibleCandidate(candidates = sortedVisibleCandidates()) {
749
- if (!candidates.some((candidate) => candidate.id === state.candidateId)) {
750
- state.candidateId = candidates[0]?.id || null;
751
- }
1086
+ function hideReviewSummary() {
1087
+ const target = el("review-summary");
1088
+ if (!target) return;
1089
+ target.classList.add("hidden");
1090
+ target.innerHTML = "";
752
1091
  }
753
1092
 
754
1093
  function renderTrapDetail() {
@@ -848,9 +1187,11 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
848
1187
  const candidate = state.candidates.find((item) => item.id === state.candidateId);
849
1188
  el("detail-meta").textContent = candidate ? candidate.id + " / " + valueLabel(candidate.status) : t("meta.selectCandidate");
850
1189
  if (!candidate) {
1190
+ state.candidateDirty = false;
851
1191
  el("detail").innerHTML = '<div class="empty">' + escapeHtml(t("empty.noCandidateSelected")) + '</div>';
852
1192
  return;
853
1193
  }
1194
+ state.candidateDirty = false;
854
1195
  const disabled = candidate.status !== "proposed" ? "disabled" : "";
855
1196
  el("detail").innerHTML = \`
856
1197
  <div class="scroll">
@@ -887,6 +1228,7 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
887
1228
  \${renderDetailActions(candidate, disabled)}
888
1229
  \`;
889
1230
  bindDetailActions(candidate);
1231
+ bindCandidateFormDirty(candidate);
890
1232
  bindTrapJumpButtons();
891
1233
  }
892
1234
 
@@ -938,9 +1280,29 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
938
1280
  <button id="accept-anyway" \${disabled}>\${escapeHtml(t("action.acceptAnyway"))}</button>
939
1281
  <input id="supersedes" placeholder="\${escapeAttr(t("placeholder.supersedesId"))}" style="width:150px" \${disabled}>
940
1282
  <button id="supersede" \${disabled}>\${escapeHtml(t("action.supersede"))}</button>
1283
+ <span id="candidate-draft-state" class="action-hint">\${escapeHtml(t("hint.acceptUsesCurrentDraft"))}</span>
941
1284
  </div>\`;
942
1285
  }
943
1286
 
1287
+ function bindCandidateFormDirty(candidate) {
1288
+ const form = el("candidate-form");
1289
+ if (!form || candidate.status !== "proposed") return;
1290
+ const markDirty = () => {
1291
+ state.candidateDirty = true;
1292
+ renderCandidateDraftState();
1293
+ };
1294
+ form.addEventListener("input", markDirty);
1295
+ form.addEventListener("change", markDirty);
1296
+ renderCandidateDraftState();
1297
+ }
1298
+
1299
+ function renderCandidateDraftState() {
1300
+ const draft = el("candidate-draft-state");
1301
+ if (!draft) return;
1302
+ draft.textContent = state.candidateDirty ? t("hint.unsavedDraftAccepted") : t("hint.acceptUsesCurrentDraft");
1303
+ draft.classList.toggle("dirty", state.candidateDirty);
1304
+ }
1305
+
944
1306
  function bindDetailActions(candidate) {
945
1307
  document.querySelectorAll("[data-clean-deleted-candidates]").forEach((button) => {
946
1308
  button.addEventListener("click", cleanupDeletedCandidates);
@@ -953,6 +1315,7 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
953
1315
  method: "POST",
954
1316
  body: JSON.stringify(candidatePayload(candidate.id))
955
1317
  });
1318
+ state.candidateDirty = false;
956
1319
  await syncAfterMutation(data.candidate.id);
957
1320
  showStatus(t("status.candidateSaved"));
958
1321
  } catch (error) {
@@ -983,12 +1346,21 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
983
1346
 
984
1347
  async function acceptCandidate(extra) {
985
1348
  try {
1349
+ const payload = el("candidate-form")
1350
+ ? candidatePayload(state.candidateId, extra)
1351
+ : {
1352
+ projectRoot: state.projectRoot,
1353
+ sessionId: state.sessionId,
1354
+ candidateId: state.candidateId,
1355
+ ...extra
1356
+ };
986
1357
  const data = await api("/api/candidate/accept", {
987
1358
  method: "POST",
988
- body: JSON.stringify({ projectRoot: state.projectRoot, sessionId: state.sessionId, candidateId: state.candidateId, ...extra })
1359
+ body: JSON.stringify(payload)
989
1360
  });
990
1361
  await syncAfterMutation(data.candidate.id);
991
1362
  state.conflicts = [];
1363
+ state.candidateDirty = false;
992
1364
  showStatus(t("status.candidateAccepted"));
993
1365
  } catch (error) {
994
1366
  if (error.payload?.possible_conflicts) {
@@ -1003,25 +1375,30 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1003
1375
  }
1004
1376
  }
1005
1377
 
1006
- function candidatePayload(candidateId) {
1007
- const form = new FormData(el("candidate-form"));
1008
- return {
1378
+ function candidatePayload(candidateId, extra = {}) {
1379
+ return reviewCandidateMutationPayload({
1009
1380
  projectRoot: state.projectRoot,
1010
1381
  sessionId: state.sessionId,
1011
1382
  candidateId,
1012
- trap: {
1013
- title: String(form.get("title") || ""),
1014
- category: String(form.get("category") || ""),
1015
- scope: String(form.get("scope") || ""),
1016
- severity: String(form.get("severity") || ""),
1017
- tags: splitList(form.get("tags")),
1018
- path_globs: splitList(form.get("path_globs")),
1019
- module: blankToNull(form.get("module")),
1020
- owner: blankToNull(form.get("owner")),
1021
- context: String(form.get("context") || ""),
1022
- mistake: String(form.get("mistake") || ""),
1023
- fix: String(form.get("fix") || "")
1024
- }
1383
+ trap: reviewCandidateTrapDraft(candidateFormFields()),
1384
+ extra
1385
+ });
1386
+ }
1387
+
1388
+ function candidateFormFields() {
1389
+ const form = new FormData(el("candidate-form"));
1390
+ return {
1391
+ title: form.get("title"),
1392
+ category: form.get("category"),
1393
+ scope: form.get("scope"),
1394
+ severity: form.get("severity"),
1395
+ tags: form.get("tags"),
1396
+ path_globs: form.get("path_globs"),
1397
+ module: form.get("module"),
1398
+ owner: form.get("owner"),
1399
+ context: form.get("context"),
1400
+ mistake: form.get("mistake"),
1401
+ fix: form.get("fix")
1025
1402
  };
1026
1403
  }
1027
1404
 
@@ -1046,6 +1423,12 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1046
1423
  }
1047
1424
 
1048
1425
  el("refresh").addEventListener("click", refreshAll);
1426
+ el("sidebar-toggle").addEventListener("click", () => {
1427
+ setSidebarCollapsed(!state.sidebarCollapsed);
1428
+ });
1429
+ el("queue-toggle").addEventListener("click", () => {
1430
+ setQueueCollapsed(!state.queueCollapsed);
1431
+ });
1049
1432
  document.querySelectorAll("[data-locale]").forEach((button) => {
1050
1433
  button.addEventListener("click", () => setLocale(button.dataset.locale));
1051
1434
  });
@@ -1059,6 +1442,8 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1059
1442
  await loadTraps();
1060
1443
  } else if (state.mainView === "insights") {
1061
1444
  await loadInsightTraps();
1445
+ } else if (state.mainView === "embeddings") {
1446
+ await loadEmbeddings();
1062
1447
  } else {
1063
1448
  await loadCandidates();
1064
1449
  }
@@ -1086,6 +1471,8 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1086
1471
  state.trapKey = null;
1087
1472
  state.trapDetails = {};
1088
1473
  state.insightTraps = [];
1474
+ state.embeddingStatus = null;
1475
+ state.embeddingSettings = null;
1089
1476
  el("project-path").value = "";
1090
1477
  renderProjects();
1091
1478
  await loadSessions();
@@ -1128,10 +1515,6 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1128
1515
  </div>\`).join("")}</div>\`;
1129
1516
  }
1130
1517
 
1131
- function statusRank(status) {
1132
- return status === "proposed" ? 0 : status === "accepted" ? 1 : 2;
1133
- }
1134
-
1135
1518
  function reviewLabel(candidate) {
1136
1519
  const review = candidate.review;
1137
1520
  if (!review || review.status === "pending") return t("review.pending");
@@ -1147,15 +1530,6 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1147
1530
  return String(candidate.review?.status || candidate.status).replace(/_/g, "-");
1148
1531
  }
1149
1532
 
1150
- function splitList(value) {
1151
- return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
1152
- }
1153
-
1154
- function blankToNull(value) {
1155
- const text = String(value || "").trim();
1156
- return text ? text : null;
1157
- }
1158
-
1159
1533
  function escapeHtml(value) {
1160
1534
  return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
1161
1535
  }
@@ -1164,5 +1538,6 @@ export function webClientScript(textJson = WEB_TEXT_JSON): string {
1164
1538
  return escapeHtml(value);
1165
1539
  }
1166
1540
 
1541
+ initShellResizers();
1167
1542
  refreshAll();`;
1168
1543
  }