@design-ai/cli 4.56.0 → 4.57.0

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 (264) hide show
  1. package/.claude-plugin/plugin.json +109 -25
  2. package/CHANGELOG.md +36 -0
  3. package/README.ko.md +9 -26
  4. package/README.md +10 -23
  5. package/cli/bin/design-ai.mjs +1 -0
  6. package/cli/commands/help.mjs +7 -4
  7. package/cli/commands/index.mjs +292 -0
  8. package/cli/commands/learn-help.mjs +149 -0
  9. package/cli/commands/learn-print-profile.mjs +373 -0
  10. package/cli/commands/learn-print-restore.mjs +349 -0
  11. package/cli/commands/learn-print-signals.mjs +453 -0
  12. package/cli/commands/learn.mjs +60 -1281
  13. package/cli/commands/pack.mjs +7 -3
  14. package/cli/commands/prompt.mjs +7 -3
  15. package/cli/commands/search.mjs +94 -1
  16. package/cli/lib/dispatch.mjs +3 -0
  17. package/cli/lib/embedding-index.mjs +199 -0
  18. package/cli/lib/embedding-provider.mjs +121 -0
  19. package/cli/lib/embedding-rerank.mjs +52 -0
  20. package/cli/lib/learn-args.mjs +490 -0
  21. package/cli/lib/learn-backup.mjs +748 -0
  22. package/cli/lib/learn-curation.mjs +612 -0
  23. package/cli/lib/learn-eval.mjs +459 -0
  24. package/cli/lib/learn-profile.mjs +763 -0
  25. package/cli/lib/learn-select.mjs +223 -0
  26. package/cli/lib/learn-shared.mjs +158 -0
  27. package/cli/lib/learn-test-support.mjs +218 -0
  28. package/cli/lib/learn-usage.mjs +360 -0
  29. package/cli/lib/learn.mjs +83 -3694
  30. package/cli/lib/lexical.mjs +137 -0
  31. package/cli/lib/local-config.mjs +110 -0
  32. package/cli/lib/mcp-server.mjs +13 -3
  33. package/cli/lib/pack.mjs +31 -4
  34. package/cli/lib/prompt.mjs +42 -4
  35. package/cli/lib/recall.mjs +193 -0
  36. package/cli/lib/retrieval-index.mjs +0 -0
  37. package/cli/lib/search-ranked.mjs +170 -0
  38. package/cli/lib/search.mjs +11 -1
  39. package/cli/lib/signals-backlog-commands.mjs +673 -0
  40. package/cli/lib/signals-backlog.mjs +361 -0
  41. package/cli/lib/signals-eval.mjs +176 -0
  42. package/cli/lib/signals-readiness.mjs +247 -0
  43. package/cli/lib/signals-registry.mjs +377 -0
  44. package/cli/lib/signals-render.mjs +478 -0
  45. package/cli/lib/signals-shared.mjs +75 -0
  46. package/cli/lib/signals.mjs +16 -2337
  47. package/cli/lib/site-bundle-handoff-expected.mjs +173 -0
  48. package/cli/lib/site-bundle-handoff-runbook-action-summary.mjs +332 -0
  49. package/cli/lib/site-bundle-handoff-runbook-human-lines.mjs +167 -0
  50. package/cli/lib/site-bundle-handoff-runbook-maps.mjs +238 -0
  51. package/cli/lib/site-bundle-handoff-runbook-next-step.mjs +278 -0
  52. package/cli/lib/site-bundle-handoff-runbook.mjs +44 -931
  53. package/cli/lib/site-test-support.mjs +68 -0
  54. package/cli/lib/skill-proposals-apply-commands.mjs +135 -0
  55. package/cli/lib/skill-proposals-apply-contract.mjs +750 -0
  56. package/cli/lib/skill-proposals-apply-plan.mjs +118 -0
  57. package/cli/lib/skill-proposals-generate.mjs +298 -0
  58. package/cli/lib/skill-proposals-render.mjs +532 -0
  59. package/cli/lib/skill-proposals-review.mjs +262 -0
  60. package/cli/lib/skill-proposals.mjs +15 -2046
  61. package/cli/lib/workspace-args.mjs +83 -0
  62. package/cli/lib/workspace-git.mjs +169 -0
  63. package/cli/lib/workspace-learning.mjs +483 -0
  64. package/cli/lib/workspace-repo.mjs +139 -0
  65. package/cli/lib/workspace-report.mjs +283 -0
  66. package/cli/lib/workspace-test-support.mjs +99 -0
  67. package/cli/lib/workspace.mjs +30 -1056
  68. package/docs/AI-LEARNING-PHASE2.md +215 -0
  69. package/docs/DISTRIBUTION.ko.md +2 -2
  70. package/docs/DISTRIBUTION.md +2 -2
  71. package/docs/NEXT-SURFACE-DECISION.md +125 -0
  72. package/docs/PRODUCT-READINESS.md +3 -3
  73. package/docs/RELEASE-GATES.ko.md +35 -0
  74. package/docs/RELEASE-GATES.md +234 -0
  75. package/docs/ROADMAP.md +53 -8
  76. package/docs/external-status.md +6 -8
  77. package/docs/inspection-20260630.md +169 -0
  78. package/docs/integrations/design-ai-mcp-server.md +4 -4
  79. package/docs/reference/ant-design.md +413 -0
  80. package/docs/reference/awesome-design-md.md +439 -0
  81. package/docs/reference/mui.md +783 -0
  82. package/docs/reference/shadcn-ui.md +298 -0
  83. package/examples/component-accordion-actions.md +5 -5
  84. package/examples/component-accordion-details.md +5 -5
  85. package/examples/component-accordion-summary.md +5 -5
  86. package/examples/component-accordion.md +3 -3
  87. package/examples/component-affix.md +1 -1
  88. package/examples/component-alert-dialog.md +1 -1
  89. package/examples/component-alert-title.md +1 -1
  90. package/examples/component-alert.md +3 -3
  91. package/examples/component-anchor.md +1 -1
  92. package/examples/component-app-bar.md +1 -1
  93. package/examples/component-aspect-ratio.md +1 -1
  94. package/examples/component-auto-complete.md +2 -2
  95. package/examples/component-avatar-group.md +5 -5
  96. package/examples/component-avatar.md +3 -3
  97. package/examples/component-back-top.md +1 -1
  98. package/examples/component-backdrop.md +1 -1
  99. package/examples/component-badge.md +3 -3
  100. package/examples/component-border-beam.md +3 -3
  101. package/examples/component-bottom-navigation.md +1 -1
  102. package/examples/component-box.md +1 -1
  103. package/examples/component-breadcrumb.md +3 -3
  104. package/examples/component-button-base.md +3 -3
  105. package/examples/component-button-group.md +2 -2
  106. package/examples/component-button.md +3 -3
  107. package/examples/component-calendar.md +2 -2
  108. package/examples/component-card-actions.md +1 -1
  109. package/examples/component-card-content.md +1 -1
  110. package/examples/component-card-header.md +1 -1
  111. package/examples/component-card-media.md +1 -1
  112. package/examples/component-card.md +3 -3
  113. package/examples/component-carousel.md +2 -2
  114. package/examples/component-cascader.md +1 -1
  115. package/examples/component-chart.md +1 -1
  116. package/examples/component-checkbox.md +3 -3
  117. package/examples/component-click-away-listener.md +1 -1
  118. package/examples/component-code.md +1 -1
  119. package/examples/component-collapsible.md +1 -1
  120. package/examples/component-color-picker.md +1 -1
  121. package/examples/component-combobox.md +1 -1
  122. package/examples/component-command.md +1 -1
  123. package/examples/component-config-provider.md +3 -3
  124. package/examples/component-context-menu.md +1 -1
  125. package/examples/component-css-baseline.md +3 -3
  126. package/examples/component-date-picker.md +2 -2
  127. package/examples/component-descriptions.md +1 -1
  128. package/examples/component-dialog-actions.md +1 -1
  129. package/examples/component-dialog-content-text.md +1 -1
  130. package/examples/component-dialog-content.md +1 -1
  131. package/examples/component-dialog-title.md +1 -1
  132. package/examples/component-dialog.md +2 -2
  133. package/examples/component-divider.md +3 -3
  134. package/examples/component-drawer.md +3 -3
  135. package/examples/component-dropdown.md +3 -3
  136. package/examples/component-empty.md +2 -2
  137. package/examples/component-fade.md +1 -1
  138. package/examples/component-field.md +1 -1
  139. package/examples/component-filled-input.md +1 -1
  140. package/examples/component-flex.md +1 -1
  141. package/examples/component-float-button.md +2 -2
  142. package/examples/component-form-control-label.md +1 -1
  143. package/examples/component-form-control.md +1 -1
  144. package/examples/component-form-controls.md +1 -1
  145. package/examples/component-form-group.md +1 -1
  146. package/examples/component-form-helper-text.md +1 -1
  147. package/examples/component-form-label.md +1 -1
  148. package/examples/component-form.md +2 -2
  149. package/examples/component-grid.md +2 -2
  150. package/examples/component-grow.md +1 -1
  151. package/examples/component-hover-card.md +1 -1
  152. package/examples/component-icon-button.md +1 -1
  153. package/examples/component-icon.md +1 -1
  154. package/examples/component-image-list.md +1 -1
  155. package/examples/component-image.md +1 -1
  156. package/examples/component-input-adornment.md +1 -1
  157. package/examples/component-input-base.md +1 -1
  158. package/examples/component-input-number.md +1 -1
  159. package/examples/component-input-otp.md +2 -2
  160. package/examples/component-input.md +3 -3
  161. package/examples/component-item.md +1 -1
  162. package/examples/component-label.md +1 -1
  163. package/examples/component-layout.md +1 -1
  164. package/examples/component-link.md +1 -1
  165. package/examples/component-list-item-avatar.md +1 -1
  166. package/examples/component-list-item-button.md +1 -1
  167. package/examples/component-list-item-icon.md +1 -1
  168. package/examples/component-list-item-text.md +1 -1
  169. package/examples/component-list-item.md +1 -1
  170. package/examples/component-list-subheader.md +1 -1
  171. package/examples/component-list.md +2 -2
  172. package/examples/component-masonry.md +1 -1
  173. package/examples/component-mentions.md +1 -1
  174. package/examples/component-menu-item.md +1 -1
  175. package/examples/component-menu-list.md +1 -1
  176. package/examples/component-menu.md +1 -1
  177. package/examples/component-menubar.md +1 -1
  178. package/examples/component-message.md +1 -1
  179. package/examples/component-mobile-stepper.md +1 -1
  180. package/examples/component-modal.md +5 -5
  181. package/examples/component-navigation-menu.md +1 -1
  182. package/examples/component-notification.md +1 -1
  183. package/examples/component-outlined-input.md +1 -1
  184. package/examples/component-pagination.md +3 -3
  185. package/examples/component-paper.md +1 -1
  186. package/examples/component-popconfirm.md +1 -1
  187. package/examples/component-popover.md +3 -3
  188. package/examples/component-popper.md +1 -1
  189. package/examples/component-progress.md +2 -2
  190. package/examples/component-qr-code.md +1 -1
  191. package/examples/component-radio.md +3 -3
  192. package/examples/component-rate.md +2 -2
  193. package/examples/component-resizable.md +1 -1
  194. package/examples/component-result.md +1 -1
  195. package/examples/component-scroll-area.md +1 -1
  196. package/examples/component-segmented.md +3 -3
  197. package/examples/component-select.md +3 -3
  198. package/examples/component-separator.md +1 -1
  199. package/examples/component-sheet.md +1 -1
  200. package/examples/component-sidebar.md +1 -1
  201. package/examples/component-skeleton.md +3 -3
  202. package/examples/component-slide.md +1 -1
  203. package/examples/component-slider.md +3 -3
  204. package/examples/component-snackbar-content.md +1 -1
  205. package/examples/component-snackbar.md +1 -1
  206. package/examples/component-sonner.md +1 -1
  207. package/examples/component-space.md +1 -1
  208. package/examples/component-speed-dial-action.md +1 -1
  209. package/examples/component-speed-dial.md +1 -1
  210. package/examples/component-spin.md +2 -2
  211. package/examples/component-spinner.md +1 -1
  212. package/examples/component-splitter.md +2 -2
  213. package/examples/component-stack.md +1 -1
  214. package/examples/component-statistic.md +1 -1
  215. package/examples/component-step-button.md +4 -4
  216. package/examples/component-step-connector.md +4 -4
  217. package/examples/component-step-content.md +1 -1
  218. package/examples/component-step-icon.md +1 -1
  219. package/examples/component-step-label.md +1 -1
  220. package/examples/component-step.md +2 -2
  221. package/examples/component-steps.md +2 -2
  222. package/examples/component-swipeable-drawer.md +1 -1
  223. package/examples/component-switch.md +3 -3
  224. package/examples/component-tab-scroll-button.md +5 -5
  225. package/examples/component-tab.md +1 -1
  226. package/examples/component-table-body.md +1 -1
  227. package/examples/component-table-cell.md +1 -1
  228. package/examples/component-table-container.md +1 -1
  229. package/examples/component-table-footer.md +1 -1
  230. package/examples/component-table-head.md +1 -1
  231. package/examples/component-table-pagination.md +1 -1
  232. package/examples/component-table-row.md +1 -1
  233. package/examples/component-table-sort-label.md +1 -1
  234. package/examples/component-table.md +2 -2
  235. package/examples/component-tabs.md +3 -3
  236. package/examples/component-tag-badge.md +2 -2
  237. package/examples/component-tag.md +1 -1
  238. package/examples/component-textarea-autosize.md +1 -1
  239. package/examples/component-textarea.md +1 -1
  240. package/examples/component-time-picker.md +1 -1
  241. package/examples/component-timeline.md +1 -1
  242. package/examples/component-toast.md +4 -4
  243. package/examples/component-toggle-button.md +1 -1
  244. package/examples/component-toggle.md +1 -1
  245. package/examples/component-toolbar.md +1 -1
  246. package/examples/component-tooltip.md +3 -3
  247. package/examples/component-tour.md +1 -1
  248. package/examples/component-transfer.md +1 -1
  249. package/examples/component-tree-select.md +1 -1
  250. package/examples/component-tree.md +1 -1
  251. package/examples/component-typography.md +2 -2
  252. package/examples/component-upload.md +1 -1
  253. package/examples/component-watermark.md +1 -1
  254. package/examples/component-zoom.md +1 -1
  255. package/knowledge/COVERAGE.md +4 -3
  256. package/knowledge/components/INDEX.md +202 -202
  257. package/knowledge/patterns/brand-references.md +72 -72
  258. package/package.json +1 -1
  259. package/tools/audit/local-ci.py +16 -1
  260. package/tools/audit/package-smoke.py +611 -9
  261. package/tools/audit/registry-smoke.py +534 -0
  262. package/tools/audit/release-metadata.py +47 -1
  263. package/tools/audit/smoke_assertions.py +915 -13
  264. package/tools/migrations/refs-links-to-reference-pages.py +179 -0
@@ -0,0 +1,193 @@
1
+ // Corpus knowledge recall (retrieval-augmentation) for `design-ai prompt`/`pack`
2
+ // --with-recall. Injects the most brief-relevant CORPUS KNOWLEDGE FILES, ranked by
3
+ // the SAME shipped deterministic lexical (BM25-style) scorer that powers
4
+ // `design-ai search --ranked` and learning selection (docs/AI-LEARNING-PHASE2.md,
5
+ // Phase A). Sibling of learning selection: learning recalls LOCAL preferences,
6
+ // recall recalls SHIPPED corpus knowledge. Zero-dependency and deterministic.
7
+ //
8
+ // NO embeddings in this path: the pack/prompt recall stays lexical-only. Embedding
9
+ // rerank remains a search-only opt-in for now (docs/AI-LEARNING-PHASE2.md, Phase B);
10
+ // there is deliberately no --embeddings recall variant.
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import path from "node:path";
14
+
15
+ import { rankedSearchCorpus, rankedPreview } from "./search-ranked.mjs";
16
+ import { collectCorpusDocuments } from "./retrieval-index.mjs";
17
+ import { loadLearningProfile } from "./learn-profile.mjs";
18
+ import { selectLearningEntrySet } from "./learn-select.mjs";
19
+ import { defaultLearningFile } from "./learn-shared.mjs";
20
+ import { DESIGN_AI_HOME } from "./paths.mjs";
21
+ import { DEFAULT_SEARCH_DIRS } from "./search.mjs";
22
+
23
+ export const DEFAULT_RECALL_LIMIT = 5;
24
+
25
+ // Mirrors parseLearningLimit but bounded 1-20 (recall injects whole corpus files, so a
26
+ // tighter cap than learning's 1-100 keeps the recall section budget-friendly).
27
+ export function parseRecallLimit(rawLimit) {
28
+ const limit = Number(rawLimit);
29
+ if (!Number.isInteger(limit) || limit < 1 || limit > 20) {
30
+ throw new Error("--recall-limit expects an integer from 1 to 20");
31
+ }
32
+ return limit;
33
+ }
34
+
35
+ // Cap each recalled file's rendered block so the total recall section stays bounded.
36
+ // The pack's takeUtf8(maxBytes) truncation still applies to the WHOLE pack as-is, so
37
+ // recall respects the byte budget by being subject to the same cap; this per-file cap
38
+ // keeps any single file from crowding out the rest of the recall section.
39
+ const RECALL_BLOCK_CHAR_CAP = 400;
40
+
41
+ // First Markdown `# ` heading, else the first non-empty line — a short, deterministic
42
+ // citation label for the recalled file.
43
+ function firstHeadingOrLine(text) {
44
+ const lines = String(text || "").split("\n");
45
+ for (const line of lines) {
46
+ const trimmed = line.trim();
47
+ if (trimmed.startsWith("# ")) return trimmed.slice(2).trim();
48
+ }
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+ if (trimmed.length > 0) return trimmed;
52
+ }
53
+ return "";
54
+ }
55
+
56
+ function readCorpusText(designAiPath, relPath) {
57
+ try {
58
+ return readFileSync(path.join(designAiPath, relPath), "utf8");
59
+ } catch {
60
+ return "";
61
+ }
62
+ }
63
+
64
+ // Bounded per-file block: relPath citation + heading/first line + matched excerpt.
65
+ // Capped to ~RECALL_BLOCK_CHAR_CAP chars so no single file dominates the section.
66
+ function renderRecallBlock({ id, text, matchedTokens }) {
67
+ const heading = firstHeadingOrLine(text);
68
+ const excerpt = rankedPreview(text, matchedTokens);
69
+ const parts = [`- ${id}`];
70
+ if (heading) parts.push(` - ${heading}`);
71
+ if (excerpt) parts.push(` - ${excerpt}`);
72
+ const block = parts.join("\n");
73
+ return block.length <= RECALL_BLOCK_CHAR_CAP
74
+ ? block
75
+ : `${block.slice(0, RECALL_BLOCK_CHAR_CAP)}...`;
76
+ }
77
+
78
+ function renderRecallMarkdown(selected, textById) {
79
+ const lines = ["## Recalled design knowledge", ""];
80
+ if (selected.length === 0) {
81
+ lines.push("No corpus knowledge files matched this brief.");
82
+ return lines.join("\n");
83
+ }
84
+
85
+ lines.push("Cite these shipped corpus knowledge files when they inform a design decision.");
86
+ lines.push("");
87
+ for (const item of selected) {
88
+ lines.push(renderRecallBlock({
89
+ id: item.id,
90
+ text: textById.get(item.id) || "",
91
+ matchedTokens: item.matchedTokens,
92
+ }));
93
+ }
94
+ return lines.join("\n");
95
+ }
96
+
97
+ // Returns a recall context mirroring learning's selection-item shape (id = corpus
98
+ // relPath). REUSES rankedSearchCorpus for scoring — no reimplemented ranking. The
99
+ // ranker already orders fully by score desc, id asc, so the result is deterministic.
100
+ // Empty brief or no lexical hits -> selectedCount 0 and a "no corpus knowledge"
101
+ // markdown line.
102
+ export function buildRecallContext({
103
+ brief = "",
104
+ recallLimit = DEFAULT_RECALL_LIMIT,
105
+ designAiPath = DESIGN_AI_HOME,
106
+ dirs = DEFAULT_SEARCH_DIRS,
107
+ } = {}) {
108
+ const query = String(brief || "").trim();
109
+ const candidateCount = collectCorpusDocuments({ designAiPath, dirs }).length;
110
+
111
+ const hits = query
112
+ ? rankedSearchCorpus({ query, designAiPath, dirs, limit: recallLimit }).hits
113
+ : [];
114
+
115
+ const selected = hits.map((hit) => ({
116
+ id: hit.relPath,
117
+ score: hit.score,
118
+ matchedTokens: hit.matchedTokens,
119
+ }));
120
+
121
+ const textById = new Map(
122
+ selected.map((item) => [item.id, readCorpusText(designAiPath, item.id)]),
123
+ );
124
+
125
+ return {
126
+ query,
127
+ mode: "lexical",
128
+ candidateCount,
129
+ selectedCount: selected.length,
130
+ selected,
131
+ markdown: renderRecallMarkdown(selected, textById),
132
+ };
133
+ }
134
+
135
+ // Read-side companion to `pack --with-recall`: a unified "what does design-ai
136
+ // recall for this query" view. Combines (1) the top corpus knowledge files ranked
137
+ // by rankedSearchCorpus with (2) the top local learning-profile entries ranked by
138
+ // selectLearningEntrySet — BOTH using the same shipped deterministic lexical scorer.
139
+ // Read-only: never writes the profile or any file. Empty query -> zero hits on both
140
+ // sides. `limit` applies to BOTH lists; `category` scopes ONLY the learning list.
141
+ export function buildLearnRecall({
142
+ query = "",
143
+ limit = DEFAULT_RECALL_LIMIT,
144
+ category = "",
145
+ designAiPath = DESIGN_AI_HOME,
146
+ learningFilePath = defaultLearningFile(),
147
+ dirs = DEFAULT_SEARCH_DIRS,
148
+ } = {}) {
149
+ const normalizedQuery = String(query || "").trim();
150
+
151
+ const corpusCandidateCount = collectCorpusDocuments({ designAiPath, dirs }).length;
152
+ const corpusHits = normalizedQuery
153
+ ? rankedSearchCorpus({ query: normalizedQuery, designAiPath, dirs, limit }).hits
154
+ : [];
155
+ const corpusSelected = corpusHits.map((hit) => ({
156
+ id: hit.relPath,
157
+ score: hit.score,
158
+ matchedTokens: hit.matchedTokens,
159
+ }));
160
+
161
+ const profile = loadLearningProfile(learningFilePath);
162
+ const textById = new Map(profile.entries.map((entry) => [entry.id, entry.text || ""]));
163
+ const { selection } = selectLearningEntrySet(profile, {
164
+ query: normalizedQuery,
165
+ limit,
166
+ category,
167
+ includeFallback: false,
168
+ });
169
+ const learningSelected = normalizedQuery
170
+ ? selection.selected.map((item) => ({
171
+ id: item.id,
172
+ category: item.category,
173
+ score: item.score,
174
+ matchedTokens: item.matchedTokens,
175
+ text: textById.get(item.id) || "",
176
+ }))
177
+ : [];
178
+
179
+ return {
180
+ query: normalizedQuery,
181
+ corpus: {
182
+ candidateCount: corpusCandidateCount,
183
+ selectedCount: corpusSelected.length,
184
+ selected: corpusSelected,
185
+ },
186
+ learning: {
187
+ mode: selection.mode,
188
+ candidateCount: selection.candidateCount,
189
+ selectedCount: learningSelected.length,
190
+ selected: learningSelected,
191
+ },
192
+ };
193
+ }
Binary file
@@ -0,0 +1,170 @@
1
+ // Ranked (BM25-style) corpus search for `design-ai search --ranked`, plus the
2
+ // opt-in Phase B embedding rerank (docs/AI-LEARNING-PHASE2.md, Phase B).
3
+ // Deterministic: ranks a live corpus scan, then reports retrieval-index staleness
4
+ // instead of ever serving stale index text (docs/AI-LEARNING-PHASE2.md, Phase A).
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ import { embeddingIndexFile, loadEmbeddingIndexFile, vectorsById as embeddingVectorsById } from "./embedding-index.mjs";
10
+ import { embedDocuments } from "./embedding-provider.mjs";
11
+ import { cosineRerank } from "./embedding-rerank.mjs";
12
+ import { buildLexicalStats, rankLexical } from "./lexical.mjs";
13
+ import { PACKAGE_ROOT } from "./paths.mjs";
14
+ import {
15
+ collectCorpusDocuments,
16
+ computeCorpusDigest,
17
+ computeLearningDigest,
18
+ corpusIndexFile,
19
+ defaultIndexDir,
20
+ loadIndexFile,
21
+ } from "./retrieval-index.mjs";
22
+ import { defaultLearningFile } from "./learn-shared.mjs";
23
+ import { DEFAULT_SEARCH_DIRS } from "./search.mjs";
24
+
25
+ const RANKED_PREVIEW_LEN = 120;
26
+
27
+ // N = max(limit*5, 25): the number of top lexical candidates handed to the embedding
28
+ // reranker. Documented constant (docs/AI-LEARNING-PHASE2.md, Phase B CLI wiring).
29
+ export function embeddingCandidateCount(limit) {
30
+ return Math.max(limit * 5, 25);
31
+ }
32
+
33
+ export function rankedPreview(text, matchedTokens) {
34
+ const lines = text.split("\n");
35
+ for (const token of matchedTokens) {
36
+ for (const line of lines) {
37
+ const trimmed = line.trim();
38
+ if (trimmed && trimmed.toLowerCase().includes(token)) {
39
+ return trimmed.length <= RANKED_PREVIEW_LEN
40
+ ? trimmed
41
+ : `${trimmed.slice(0, RANKED_PREVIEW_LEN)}...`;
42
+ }
43
+ }
44
+ }
45
+ const firstLine = lines.map((line) => line.trim()).find((line) => line.length > 0) || "";
46
+ return firstLine.length <= RANKED_PREVIEW_LEN ? firstLine : `${firstLine.slice(0, RANKED_PREVIEW_LEN)}...`;
47
+ }
48
+
49
+ function corpusIndexNotice({ indexDir, currentDigest }) {
50
+ const loaded = loadIndexFile(corpusIndexFile(indexDir));
51
+ if (!loaded.present) {
52
+ return "no corpus index built yet; ranked results come from a live corpus scan (design-ai index --build)";
53
+ }
54
+ if (loaded.error) {
55
+ return `corpus index is unreadable (${loaded.error}); ranked results come from a live corpus scan (design-ai index --build)`;
56
+ }
57
+ if ((loaded.payload.source?.corpusDigest || "") !== currentDigest) {
58
+ return "corpus index is stale; ranked results come from a live corpus scan (design-ai index --build)";
59
+ }
60
+ return "";
61
+ }
62
+
63
+ export function rankedSearchCorpus({
64
+ query,
65
+ designAiPath = PACKAGE_ROOT,
66
+ dirs = DEFAULT_SEARCH_DIRS,
67
+ limit = 20,
68
+ indexDir = defaultIndexDir(),
69
+ } = {}) {
70
+ const documents = collectCorpusDocuments({ designAiPath, dirs });
71
+ const stats = buildLexicalStats(documents.map(({ id, text }) => ({ id, text })));
72
+ const textById = new Map(documents.map((doc) => [doc.id, doc.text]));
73
+
74
+ const hits = rankLexical(query, stats, { limit }).map((hit) => ({
75
+ relPath: hit.id,
76
+ file: path.join(designAiPath, hit.id),
77
+ score: hit.score,
78
+ matchedTokens: hit.matchedTokens,
79
+ preview: rankedPreview(textById.get(hit.id) || "", hit.matchedTokens),
80
+ }));
81
+
82
+ return {
83
+ hits,
84
+ notice: corpusIndexNotice({ indexDir, currentDigest: computeCorpusDigest(documents) }),
85
+ };
86
+ }
87
+
88
+ function embeddingSidecarFreshness({ indexDir, designAiPath, learningFile, corpusDigest }) {
89
+ const file = embeddingIndexFile(indexDir);
90
+ const loaded = loadEmbeddingIndexFile(file);
91
+ if (!loaded.present) return { ok: false, reason: "no embedding index built yet (design-ai index --build --embeddings)" };
92
+ if (loaded.error) return { ok: false, reason: `embedding index is unreadable (${loaded.error})` };
93
+
94
+ const source = loaded.payload.source || {};
95
+ const sourceMatch =
96
+ source.designAiPath === path.resolve(designAiPath) && source.learningFile === path.resolve(learningFile);
97
+ if (!sourceMatch) {
98
+ return { ok: false, reason: "embedding index was built from a different checkout or learning file" };
99
+ }
100
+
101
+ const { digest: currentLearningDigest } = computeLearningDigest(learningFile);
102
+ const digestMatch = source.corpusDigest === corpusDigest && source.learningDigest === currentLearningDigest;
103
+ if (!digestMatch) {
104
+ return { ok: false, reason: "embedding index is stale (design-ai index --build --embeddings)" };
105
+ }
106
+
107
+ return { ok: true, payload: loaded.payload };
108
+ }
109
+
110
+ // Opt-in Phase B rerank: take the top-N lexical candidates, embed the QUERY via the
111
+ // provider, cosine-rerank against the stored sidecar vectors, return the top `limit`.
112
+ // ANY failure (no provider, no sidecar, stale sidecar, provider error) is reported
113
+ // via `fallback: true` + a specific `notice` — callers must fall back to the lexical
114
+ // path and exit 0; a stale sidecar must never silently serve embedding results.
115
+ export function embeddingRerankSearch({
116
+ query,
117
+ provider,
118
+ designAiPath = PACKAGE_ROOT,
119
+ dirs = DEFAULT_SEARCH_DIRS,
120
+ limit = 20,
121
+ indexDir = defaultIndexDir(),
122
+ learningFile = defaultLearningFile(),
123
+ spawnRunner,
124
+ } = {}) {
125
+ if (!provider || typeof provider.command !== "string" || provider.command.trim() === "") {
126
+ return { fallback: true, notice: "no embedding provider configured (pass --provider or configure ~/.design-ai/config.json)" };
127
+ }
128
+
129
+ // Freshness is checked against the FULL corpus (the same dirs `index --build
130
+ // --embeddings` always embeds), independent of this search's --dir filter — a
131
+ // scoped `--dir knowledge` search must not be compared against a digest of only
132
+ // the knowledge dir, or every scoped search would spuriously report the sidecar
133
+ // stale. Candidate generation below still respects the caller's `dirs` filter.
134
+ const fullCorpusDocuments = collectCorpusDocuments({ designAiPath, dirs: DEFAULT_SEARCH_DIRS });
135
+ const corpusDigest = computeCorpusDigest(fullCorpusDocuments);
136
+ const freshness = embeddingSidecarFreshness({ indexDir, designAiPath, learningFile, corpusDigest });
137
+ if (!freshness.ok) return { fallback: true, notice: freshness.reason };
138
+
139
+ const documents = dirs === DEFAULT_SEARCH_DIRS ? fullCorpusDocuments : collectCorpusDocuments({ designAiPath, dirs });
140
+ const candidateCount = embeddingCandidateCount(limit);
141
+ const stats = buildLexicalStats(documents.map(({ id, text }) => ({ id, text })));
142
+ const textById = new Map(documents.map((doc) => [doc.id, doc.text]));
143
+ const candidates = rankLexical(query, stats, { limit: candidateCount });
144
+ if (candidates.length === 0) {
145
+ return { fallback: true, notice: "no lexical candidates matched the query; nothing to rerank" };
146
+ }
147
+
148
+ const embedArgs = { provider, documents: [{ id: "__query__", text: query }] };
149
+ if (spawnRunner) embedArgs.spawnRunner = spawnRunner;
150
+ const embedded = embedDocuments(embedArgs);
151
+ if (!embedded.ok) return { fallback: true, notice: `embedding provider failed: ${embedded.error}` };
152
+
153
+ const queryVector = embedded.vectorsById.get("__query__");
154
+ const vectors = embeddingVectorsById(freshness.payload);
155
+ const reranked = cosineRerank({ queryVector, candidates, vectorsById: vectors, limit });
156
+
157
+ const hits = reranked.map((hit) => ({
158
+ relPath: hit.id,
159
+ file: path.join(designAiPath, hit.id),
160
+ // `score` is a cosine similarity for embedded candidates; a candidate the
161
+ // provider had no vector for (placed after all embedded ones, see
162
+ // embedding-rerank.mjs) keeps its lexical BM25 score here as a documented,
163
+ // deterministic fallback value rather than an undefined/null field.
164
+ score: hit.score,
165
+ matchedTokens: hit.matchedTokens,
166
+ preview: rankedPreview(textById.get(hit.id) || "", hit.matchedTokens),
167
+ }));
168
+
169
+ return { fallback: false, hits };
170
+ }
@@ -20,7 +20,7 @@ export const DEFAULT_SEARCH_DIRS = [
20
20
 
21
21
  const PREVIEW_LEN = 120;
22
22
  const PREVIEW_BEFORE = 50;
23
- const SEARCH_OPTIONS = ["-h", "--help", "--json", "--limit", "--dir"];
23
+ const SEARCH_OPTIONS = ["-h", "--help", "--json", "--limit", "--dir", "--ranked", "--embeddings", "--provider"];
24
24
 
25
25
  function exists(p) {
26
26
  try {
@@ -115,6 +115,9 @@ export function parseSearchArgs(args) {
115
115
  dirs: [],
116
116
  limit: 20,
117
117
  json: false,
118
+ ranked: false,
119
+ embeddings: false,
120
+ provider: "",
118
121
  help: false,
119
122
  };
120
123
 
@@ -124,6 +127,13 @@ export function parseSearchArgs(args) {
124
127
  out.help = true;
125
128
  } else if (arg === "--json") {
126
129
  out.json = true;
130
+ } else if (arg === "--ranked") {
131
+ out.ranked = true;
132
+ } else if (arg === "--embeddings") {
133
+ out.embeddings = true;
134
+ } else if (arg === "--provider") {
135
+ out.provider = args[i + 1] || "";
136
+ i += 1;
127
137
  } else if (arg === "--limit") {
128
138
  const next = args[i + 1];
129
139
  const limit = Number(next);