@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.
- package/.claude-plugin/plugin.json +109 -25
- package/CHANGELOG.md +36 -0
- package/README.ko.md +9 -26
- package/README.md +10 -23
- package/cli/bin/design-ai.mjs +1 -0
- package/cli/commands/help.mjs +7 -4
- package/cli/commands/index.mjs +292 -0
- package/cli/commands/learn-help.mjs +149 -0
- package/cli/commands/learn-print-profile.mjs +373 -0
- package/cli/commands/learn-print-restore.mjs +349 -0
- package/cli/commands/learn-print-signals.mjs +453 -0
- package/cli/commands/learn.mjs +60 -1281
- package/cli/commands/pack.mjs +7 -3
- package/cli/commands/prompt.mjs +7 -3
- package/cli/commands/search.mjs +94 -1
- package/cli/lib/dispatch.mjs +3 -0
- package/cli/lib/embedding-index.mjs +199 -0
- package/cli/lib/embedding-provider.mjs +121 -0
- package/cli/lib/embedding-rerank.mjs +52 -0
- package/cli/lib/learn-args.mjs +490 -0
- package/cli/lib/learn-backup.mjs +748 -0
- package/cli/lib/learn-curation.mjs +612 -0
- package/cli/lib/learn-eval.mjs +459 -0
- package/cli/lib/learn-profile.mjs +763 -0
- package/cli/lib/learn-select.mjs +223 -0
- package/cli/lib/learn-shared.mjs +158 -0
- package/cli/lib/learn-test-support.mjs +218 -0
- package/cli/lib/learn-usage.mjs +360 -0
- package/cli/lib/learn.mjs +83 -3694
- package/cli/lib/lexical.mjs +137 -0
- package/cli/lib/local-config.mjs +110 -0
- package/cli/lib/mcp-server.mjs +13 -3
- package/cli/lib/pack.mjs +31 -4
- package/cli/lib/prompt.mjs +42 -4
- package/cli/lib/recall.mjs +193 -0
- package/cli/lib/retrieval-index.mjs +0 -0
- package/cli/lib/search-ranked.mjs +170 -0
- package/cli/lib/search.mjs +11 -1
- package/cli/lib/signals-backlog-commands.mjs +673 -0
- package/cli/lib/signals-backlog.mjs +361 -0
- package/cli/lib/signals-eval.mjs +176 -0
- package/cli/lib/signals-readiness.mjs +247 -0
- package/cli/lib/signals-registry.mjs +377 -0
- package/cli/lib/signals-render.mjs +478 -0
- package/cli/lib/signals-shared.mjs +75 -0
- package/cli/lib/signals.mjs +16 -2337
- package/cli/lib/site-bundle-handoff-expected.mjs +173 -0
- package/cli/lib/site-bundle-handoff-runbook-action-summary.mjs +332 -0
- package/cli/lib/site-bundle-handoff-runbook-human-lines.mjs +167 -0
- package/cli/lib/site-bundle-handoff-runbook-maps.mjs +238 -0
- package/cli/lib/site-bundle-handoff-runbook-next-step.mjs +278 -0
- package/cli/lib/site-bundle-handoff-runbook.mjs +44 -931
- package/cli/lib/site-test-support.mjs +68 -0
- package/cli/lib/skill-proposals-apply-commands.mjs +135 -0
- package/cli/lib/skill-proposals-apply-contract.mjs +750 -0
- package/cli/lib/skill-proposals-apply-plan.mjs +118 -0
- package/cli/lib/skill-proposals-generate.mjs +298 -0
- package/cli/lib/skill-proposals-render.mjs +532 -0
- package/cli/lib/skill-proposals-review.mjs +262 -0
- package/cli/lib/skill-proposals.mjs +15 -2046
- package/cli/lib/workspace-args.mjs +83 -0
- package/cli/lib/workspace-git.mjs +169 -0
- package/cli/lib/workspace-learning.mjs +483 -0
- package/cli/lib/workspace-repo.mjs +139 -0
- package/cli/lib/workspace-report.mjs +283 -0
- package/cli/lib/workspace-test-support.mjs +99 -0
- package/cli/lib/workspace.mjs +30 -1056
- package/docs/AI-LEARNING-PHASE2.md +215 -0
- package/docs/DISTRIBUTION.ko.md +2 -2
- package/docs/DISTRIBUTION.md +2 -2
- package/docs/NEXT-SURFACE-DECISION.md +125 -0
- package/docs/PRODUCT-READINESS.md +3 -3
- package/docs/RELEASE-GATES.ko.md +35 -0
- package/docs/RELEASE-GATES.md +234 -0
- package/docs/ROADMAP.md +53 -8
- package/docs/external-status.md +6 -8
- package/docs/inspection-20260630.md +169 -0
- package/docs/integrations/design-ai-mcp-server.md +4 -4
- package/docs/reference/ant-design.md +413 -0
- package/docs/reference/awesome-design-md.md +439 -0
- package/docs/reference/mui.md +783 -0
- package/docs/reference/shadcn-ui.md +298 -0
- package/examples/component-accordion-actions.md +5 -5
- package/examples/component-accordion-details.md +5 -5
- package/examples/component-accordion-summary.md +5 -5
- package/examples/component-accordion.md +3 -3
- package/examples/component-affix.md +1 -1
- package/examples/component-alert-dialog.md +1 -1
- package/examples/component-alert-title.md +1 -1
- package/examples/component-alert.md +3 -3
- package/examples/component-anchor.md +1 -1
- package/examples/component-app-bar.md +1 -1
- package/examples/component-aspect-ratio.md +1 -1
- package/examples/component-auto-complete.md +2 -2
- package/examples/component-avatar-group.md +5 -5
- package/examples/component-avatar.md +3 -3
- package/examples/component-back-top.md +1 -1
- package/examples/component-backdrop.md +1 -1
- package/examples/component-badge.md +3 -3
- package/examples/component-border-beam.md +3 -3
- package/examples/component-bottom-navigation.md +1 -1
- package/examples/component-box.md +1 -1
- package/examples/component-breadcrumb.md +3 -3
- package/examples/component-button-base.md +3 -3
- package/examples/component-button-group.md +2 -2
- package/examples/component-button.md +3 -3
- package/examples/component-calendar.md +2 -2
- package/examples/component-card-actions.md +1 -1
- package/examples/component-card-content.md +1 -1
- package/examples/component-card-header.md +1 -1
- package/examples/component-card-media.md +1 -1
- package/examples/component-card.md +3 -3
- package/examples/component-carousel.md +2 -2
- package/examples/component-cascader.md +1 -1
- package/examples/component-chart.md +1 -1
- package/examples/component-checkbox.md +3 -3
- package/examples/component-click-away-listener.md +1 -1
- package/examples/component-code.md +1 -1
- package/examples/component-collapsible.md +1 -1
- package/examples/component-color-picker.md +1 -1
- package/examples/component-combobox.md +1 -1
- package/examples/component-command.md +1 -1
- package/examples/component-config-provider.md +3 -3
- package/examples/component-context-menu.md +1 -1
- package/examples/component-css-baseline.md +3 -3
- package/examples/component-date-picker.md +2 -2
- package/examples/component-descriptions.md +1 -1
- package/examples/component-dialog-actions.md +1 -1
- package/examples/component-dialog-content-text.md +1 -1
- package/examples/component-dialog-content.md +1 -1
- package/examples/component-dialog-title.md +1 -1
- package/examples/component-dialog.md +2 -2
- package/examples/component-divider.md +3 -3
- package/examples/component-drawer.md +3 -3
- package/examples/component-dropdown.md +3 -3
- package/examples/component-empty.md +2 -2
- package/examples/component-fade.md +1 -1
- package/examples/component-field.md +1 -1
- package/examples/component-filled-input.md +1 -1
- package/examples/component-flex.md +1 -1
- package/examples/component-float-button.md +2 -2
- package/examples/component-form-control-label.md +1 -1
- package/examples/component-form-control.md +1 -1
- package/examples/component-form-controls.md +1 -1
- package/examples/component-form-group.md +1 -1
- package/examples/component-form-helper-text.md +1 -1
- package/examples/component-form-label.md +1 -1
- package/examples/component-form.md +2 -2
- package/examples/component-grid.md +2 -2
- package/examples/component-grow.md +1 -1
- package/examples/component-hover-card.md +1 -1
- package/examples/component-icon-button.md +1 -1
- package/examples/component-icon.md +1 -1
- package/examples/component-image-list.md +1 -1
- package/examples/component-image.md +1 -1
- package/examples/component-input-adornment.md +1 -1
- package/examples/component-input-base.md +1 -1
- package/examples/component-input-number.md +1 -1
- package/examples/component-input-otp.md +2 -2
- package/examples/component-input.md +3 -3
- package/examples/component-item.md +1 -1
- package/examples/component-label.md +1 -1
- package/examples/component-layout.md +1 -1
- package/examples/component-link.md +1 -1
- package/examples/component-list-item-avatar.md +1 -1
- package/examples/component-list-item-button.md +1 -1
- package/examples/component-list-item-icon.md +1 -1
- package/examples/component-list-item-text.md +1 -1
- package/examples/component-list-item.md +1 -1
- package/examples/component-list-subheader.md +1 -1
- package/examples/component-list.md +2 -2
- package/examples/component-masonry.md +1 -1
- package/examples/component-mentions.md +1 -1
- package/examples/component-menu-item.md +1 -1
- package/examples/component-menu-list.md +1 -1
- package/examples/component-menu.md +1 -1
- package/examples/component-menubar.md +1 -1
- package/examples/component-message.md +1 -1
- package/examples/component-mobile-stepper.md +1 -1
- package/examples/component-modal.md +5 -5
- package/examples/component-navigation-menu.md +1 -1
- package/examples/component-notification.md +1 -1
- package/examples/component-outlined-input.md +1 -1
- package/examples/component-pagination.md +3 -3
- package/examples/component-paper.md +1 -1
- package/examples/component-popconfirm.md +1 -1
- package/examples/component-popover.md +3 -3
- package/examples/component-popper.md +1 -1
- package/examples/component-progress.md +2 -2
- package/examples/component-qr-code.md +1 -1
- package/examples/component-radio.md +3 -3
- package/examples/component-rate.md +2 -2
- package/examples/component-resizable.md +1 -1
- package/examples/component-result.md +1 -1
- package/examples/component-scroll-area.md +1 -1
- package/examples/component-segmented.md +3 -3
- package/examples/component-select.md +3 -3
- package/examples/component-separator.md +1 -1
- package/examples/component-sheet.md +1 -1
- package/examples/component-sidebar.md +1 -1
- package/examples/component-skeleton.md +3 -3
- package/examples/component-slide.md +1 -1
- package/examples/component-slider.md +3 -3
- package/examples/component-snackbar-content.md +1 -1
- package/examples/component-snackbar.md +1 -1
- package/examples/component-sonner.md +1 -1
- package/examples/component-space.md +1 -1
- package/examples/component-speed-dial-action.md +1 -1
- package/examples/component-speed-dial.md +1 -1
- package/examples/component-spin.md +2 -2
- package/examples/component-spinner.md +1 -1
- package/examples/component-splitter.md +2 -2
- package/examples/component-stack.md +1 -1
- package/examples/component-statistic.md +1 -1
- package/examples/component-step-button.md +4 -4
- package/examples/component-step-connector.md +4 -4
- package/examples/component-step-content.md +1 -1
- package/examples/component-step-icon.md +1 -1
- package/examples/component-step-label.md +1 -1
- package/examples/component-step.md +2 -2
- package/examples/component-steps.md +2 -2
- package/examples/component-swipeable-drawer.md +1 -1
- package/examples/component-switch.md +3 -3
- package/examples/component-tab-scroll-button.md +5 -5
- package/examples/component-tab.md +1 -1
- package/examples/component-table-body.md +1 -1
- package/examples/component-table-cell.md +1 -1
- package/examples/component-table-container.md +1 -1
- package/examples/component-table-footer.md +1 -1
- package/examples/component-table-head.md +1 -1
- package/examples/component-table-pagination.md +1 -1
- package/examples/component-table-row.md +1 -1
- package/examples/component-table-sort-label.md +1 -1
- package/examples/component-table.md +2 -2
- package/examples/component-tabs.md +3 -3
- package/examples/component-tag-badge.md +2 -2
- package/examples/component-tag.md +1 -1
- package/examples/component-textarea-autosize.md +1 -1
- package/examples/component-textarea.md +1 -1
- package/examples/component-time-picker.md +1 -1
- package/examples/component-timeline.md +1 -1
- package/examples/component-toast.md +4 -4
- package/examples/component-toggle-button.md +1 -1
- package/examples/component-toggle.md +1 -1
- package/examples/component-toolbar.md +1 -1
- package/examples/component-tooltip.md +3 -3
- package/examples/component-tour.md +1 -1
- package/examples/component-transfer.md +1 -1
- package/examples/component-tree-select.md +1 -1
- package/examples/component-tree.md +1 -1
- package/examples/component-typography.md +2 -2
- package/examples/component-upload.md +1 -1
- package/examples/component-watermark.md +1 -1
- package/examples/component-zoom.md +1 -1
- package/knowledge/COVERAGE.md +4 -3
- package/knowledge/components/INDEX.md +202 -202
- package/knowledge/patterns/brand-references.md +72 -72
- package/package.json +1 -1
- package/tools/audit/local-ci.py +16 -1
- package/tools/audit/package-smoke.py +611 -9
- package/tools/audit/registry-smoke.py +534 -0
- package/tools/audit/release-metadata.py +47 -1
- package/tools/audit/smoke_assertions.py +915 -13
- 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
|
+
}
|
package/cli/lib/search.mjs
CHANGED
|
@@ -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);
|