@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
package/cli/lib/learn.mjs
CHANGED
|
@@ -1,3694 +1,83 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
];
|
|
85
|
-
export const LEARNING_FEEDBACK_OUTCOMES = [
|
|
86
|
-
"keep",
|
|
87
|
-
"improve",
|
|
88
|
-
"avoid",
|
|
89
|
-
];
|
|
90
|
-
export const LEARNING_INIT_SOURCE = "init:local-dogfood";
|
|
91
|
-
export const LEARNING_INIT_ENTRIES = [
|
|
92
|
-
{
|
|
93
|
-
category: "preference",
|
|
94
|
-
text: "Prefer concise, evidence-led design recommendations with one best path and explicit tradeoffs.",
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
category: "workflow",
|
|
98
|
-
text: "For implementation work, inspect repository context first, keep edits scoped, and run meaningful verification before handoff.",
|
|
99
|
-
},
|
|
100
|
-
{
|
|
101
|
-
category: "accessibility",
|
|
102
|
-
text: "For non-trivial UI, include keyboard navigation, visible focus, screen-reader behavior, and WCAG 2.1 AA contrast notes.",
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
category: "korean",
|
|
106
|
-
text: "When Korean users or Korean copy are involved, use Pretendard, Korean typography line-height, dense mobile conventions, and a consistent honorific level.",
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
category: "brand",
|
|
110
|
-
text: "Use restrained product UI language for internal tools and avoid decorative marketing phrasing unless explicitly requested.",
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
category: "constraint",
|
|
114
|
-
text: "Do not add external AI APIs, embeddings, telemetry, or fine-tuning behavior without explicit approval.",
|
|
115
|
-
},
|
|
116
|
-
];
|
|
117
|
-
const DEFAULT_AUDIT_MAX_ENTRY_CHARS = 800;
|
|
118
|
-
const LEARNING_SENSITIVE_PATTERNS = [
|
|
119
|
-
{
|
|
120
|
-
code: "private-key",
|
|
121
|
-
label: "a private key block",
|
|
122
|
-
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
code: "secret-assignment",
|
|
126
|
-
label: "a secret-like assignment",
|
|
127
|
-
pattern: /\b(?:api[_-]?key|secret|token|password)\b\s*[:=]/i,
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
code: "openai-secret-key",
|
|
131
|
-
label: "an OpenAI-style secret key",
|
|
132
|
-
pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/,
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
code: "email-address",
|
|
136
|
-
label: "an email address",
|
|
137
|
-
pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
code: "kr-phone-number",
|
|
141
|
-
label: "a Korean mobile phone number",
|
|
142
|
-
pattern: /\b01[016789]-?\d{3,4}-?\d{4}\b/,
|
|
143
|
-
},
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
export function defaultLearningFile() {
|
|
147
|
-
return process.env.DESIGN_AI_LEARNING_FILE || DEFAULT_LEARNING_FILE;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function defaultLearningUsageFile(filePath = defaultLearningFile()) {
|
|
151
|
-
if (process.env.DESIGN_AI_LEARNING_USAGE_FILE) {
|
|
152
|
-
return process.env.DESIGN_AI_LEARNING_USAGE_FILE;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const parsed = path.parse(filePath);
|
|
156
|
-
const ext = parsed.ext || ".json";
|
|
157
|
-
return path.join(parsed.dir, `${parsed.name}.usage${ext}`);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export function defaultLearningRestoreBackupFile(filePath = defaultLearningFile(), now = new Date()) {
|
|
161
|
-
const resolvedFile = path.resolve(filePath);
|
|
162
|
-
const parsed = path.parse(resolvedFile);
|
|
163
|
-
const ext = parsed.ext || ".json";
|
|
164
|
-
const timestamp = (now instanceof Date ? now : new Date(now))
|
|
165
|
-
.toISOString()
|
|
166
|
-
.replace(/[-:]/g, "")
|
|
167
|
-
.replace(/\./g, "");
|
|
168
|
-
return path.join(parsed.dir, `${parsed.name}.restore-backup-${timestamp}${ext}`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function learningRestoreBackupPattern(filePath = defaultLearningFile()) {
|
|
172
|
-
const resolvedFile = path.resolve(filePath);
|
|
173
|
-
const parsed = path.parse(resolvedFile);
|
|
174
|
-
const ext = parsed.ext || ".json";
|
|
175
|
-
return {
|
|
176
|
-
directory: parsed.dir,
|
|
177
|
-
prefix: `${parsed.name}.restore-backup-`,
|
|
178
|
-
ext,
|
|
179
|
-
glob: `${parsed.name}.restore-backup-*${ext}`,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function parseRestoreBackupCreatedAt(fileName, { prefix, ext }) {
|
|
184
|
-
if (!fileName.startsWith(prefix) || !fileName.endsWith(ext)) return "";
|
|
185
|
-
const timestamp = fileName.slice(prefix.length, fileName.length - ext.length);
|
|
186
|
-
const match = timestamp.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z$/);
|
|
187
|
-
if (!match) return "";
|
|
188
|
-
const [, year, month, day, hour, minute, second, millisecond] = match;
|
|
189
|
-
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${millisecond}Z`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function setAction(out, action) {
|
|
193
|
-
if (out.action && out.action !== action) {
|
|
194
|
-
throw new Error(`Choose only one learning action: --${out.action} or --${action}`);
|
|
195
|
-
}
|
|
196
|
-
out.action = action;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function normalizeCategory(rawCategory = "preference") {
|
|
200
|
-
const category = String(rawCategory || "preference").trim().toLowerCase();
|
|
201
|
-
if (!LEARNING_CATEGORIES.includes(category)) {
|
|
202
|
-
throw new Error(expectedValueMessage("category", category, LEARNING_CATEGORIES));
|
|
203
|
-
}
|
|
204
|
-
return category;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function normalizeFeedbackOutcome(rawOutcome = "improve") {
|
|
208
|
-
const outcome = String(rawOutcome || "improve").trim().toLowerCase();
|
|
209
|
-
if (!LEARNING_FEEDBACK_OUTCOMES.includes(outcome)) {
|
|
210
|
-
throw new Error(expectedValueMessage("outcome", outcome, LEARNING_FEEDBACK_OUTCOMES));
|
|
211
|
-
}
|
|
212
|
-
return outcome;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export function parseLearningLimit(rawLimit) {
|
|
216
|
-
const limit = Number(rawLimit);
|
|
217
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
218
|
-
throw new Error("--limit expects an integer from 1 to 100");
|
|
219
|
-
}
|
|
220
|
-
return limit;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function parseLearningKeep(rawKeep) {
|
|
224
|
-
const keep = Number(rawKeep);
|
|
225
|
-
if (!Number.isInteger(keep) || keep < 1 || keep > 100) {
|
|
226
|
-
throw new Error("--keep expects an integer from 1 to 100");
|
|
227
|
-
}
|
|
228
|
-
return keep;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function parseLearningMinEvidence(rawMinEvidence) {
|
|
232
|
-
const minEvidence = Number(rawMinEvidence);
|
|
233
|
-
if (!Number.isInteger(minEvidence) || minEvidence < 1 || minEvidence > 100) {
|
|
234
|
-
throw new Error("--min-evidence expects an integer from 1 to 100");
|
|
235
|
-
}
|
|
236
|
-
return minEvidence;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
export function parseLearnArgs(args) {
|
|
240
|
-
const out = {
|
|
241
|
-
action: "",
|
|
242
|
-
noteParts: [],
|
|
243
|
-
fromFile: "",
|
|
244
|
-
stdin: false,
|
|
245
|
-
category: "preference",
|
|
246
|
-
categorySpecified: false,
|
|
247
|
-
feedbackOutcome: "improve",
|
|
248
|
-
outcomeSpecified: false,
|
|
249
|
-
filePath: "",
|
|
250
|
-
usageFilePath: "",
|
|
251
|
-
reviewFilePath: "",
|
|
252
|
-
backupFilePath: "",
|
|
253
|
-
outPath: "",
|
|
254
|
-
force: false,
|
|
255
|
-
query: "",
|
|
256
|
-
explain: false,
|
|
257
|
-
forgetTarget: "",
|
|
258
|
-
limit: 0,
|
|
259
|
-
minEvidenceCount: 0,
|
|
260
|
-
keep: 0,
|
|
261
|
-
fix: false,
|
|
262
|
-
prune: false,
|
|
263
|
-
dryRun: false,
|
|
264
|
-
strict: false,
|
|
265
|
-
report: false,
|
|
266
|
-
applyPlan: false,
|
|
267
|
-
patch: false,
|
|
268
|
-
reviewCheck: false,
|
|
269
|
-
reviewTemplate: false,
|
|
270
|
-
yes: false,
|
|
271
|
-
json: false,
|
|
272
|
-
help: false,
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
276
|
-
const arg = args[i];
|
|
277
|
-
out.index = i;
|
|
278
|
-
|
|
279
|
-
if (arg === "-h" || arg === "--help") {
|
|
280
|
-
out.help = true;
|
|
281
|
-
} else if (arg === "--json") {
|
|
282
|
-
out.json = true;
|
|
283
|
-
} else if (arg === "--init") {
|
|
284
|
-
setAction(out, "init");
|
|
285
|
-
} else if (arg === "--remember") {
|
|
286
|
-
setAction(out, "remember");
|
|
287
|
-
} else if (arg === "--feedback") {
|
|
288
|
-
setAction(out, "feedback");
|
|
289
|
-
} else if (arg === "--list") {
|
|
290
|
-
setAction(out, "list");
|
|
291
|
-
} else if (arg === "--export") {
|
|
292
|
-
setAction(out, "export");
|
|
293
|
-
} else if (arg === "--import") {
|
|
294
|
-
setAction(out, "import");
|
|
295
|
-
} else if (arg === "--backup") {
|
|
296
|
-
setAction(out, "backup");
|
|
297
|
-
} else if (arg === "--verify") {
|
|
298
|
-
setAction(out, "verify");
|
|
299
|
-
} else if (arg === "--diff") {
|
|
300
|
-
setAction(out, "diff");
|
|
301
|
-
} else if (arg === "--restore") {
|
|
302
|
-
setAction(out, "restore");
|
|
303
|
-
} else if (arg === "--restore-backups") {
|
|
304
|
-
setAction(out, "restore-backups");
|
|
305
|
-
} else if (arg === "--prune") {
|
|
306
|
-
out.prune = true;
|
|
307
|
-
} else if (arg === "--redact") {
|
|
308
|
-
setAction(out, "redact");
|
|
309
|
-
} else if (arg === "--audit") {
|
|
310
|
-
setAction(out, "audit");
|
|
311
|
-
} else if (arg === "--stats") {
|
|
312
|
-
setAction(out, "stats");
|
|
313
|
-
} else if (arg === "--usage") {
|
|
314
|
-
setAction(out, "usage");
|
|
315
|
-
} else if (arg === "--signals") {
|
|
316
|
-
setAction(out, "signals");
|
|
317
|
-
} else if (arg === "--agent-backlog") {
|
|
318
|
-
setAction(out, "agent-backlog");
|
|
319
|
-
} else if (arg === "--propose-skills") {
|
|
320
|
-
setAction(out, "propose-skills");
|
|
321
|
-
} else if (arg === "--eval") {
|
|
322
|
-
setAction(out, "eval");
|
|
323
|
-
} else if (arg === "--eval-template") {
|
|
324
|
-
setAction(out, "eval-template");
|
|
325
|
-
} else if (arg === "--curate") {
|
|
326
|
-
setAction(out, "curate");
|
|
327
|
-
} else if (arg === "--report") {
|
|
328
|
-
out.report = true;
|
|
329
|
-
} else if (arg === "--apply-plan") {
|
|
330
|
-
out.applyPlan = true;
|
|
331
|
-
} else if (arg === "--patch") {
|
|
332
|
-
out.patch = true;
|
|
333
|
-
} else if (arg === "--review-check") {
|
|
334
|
-
out.reviewCheck = true;
|
|
335
|
-
} else if (arg === "--review-template") {
|
|
336
|
-
out.reviewTemplate = true;
|
|
337
|
-
} else if (arg === "--fix") {
|
|
338
|
-
out.fix = true;
|
|
339
|
-
} else if (arg === "--dry-run") {
|
|
340
|
-
out.dryRun = true;
|
|
341
|
-
} else if (arg === "--strict") {
|
|
342
|
-
out.strict = true;
|
|
343
|
-
} else if (arg === "--query") {
|
|
344
|
-
const query = args[i + 1];
|
|
345
|
-
if (!query || query.startsWith("--")) throw new Error("--query expects search text");
|
|
346
|
-
out.query = String(query).trim();
|
|
347
|
-
i += 1;
|
|
348
|
-
} else if (arg === "--explain") {
|
|
349
|
-
out.explain = true;
|
|
350
|
-
} else if (arg === "--outcome") {
|
|
351
|
-
const outcome = args[i + 1];
|
|
352
|
-
if (!outcome || outcome.startsWith("--")) throw new Error("--outcome expects keep, improve, or avoid");
|
|
353
|
-
out.feedbackOutcome = normalizeFeedbackOutcome(outcome);
|
|
354
|
-
out.outcomeSpecified = true;
|
|
355
|
-
i += 1;
|
|
356
|
-
} else if (arg === "--forget") {
|
|
357
|
-
setAction(out, "forget");
|
|
358
|
-
const target = args[i + 1];
|
|
359
|
-
if (!target || target.startsWith("--")) throw new Error("--forget expects an entry id or list number");
|
|
360
|
-
out.forgetTarget = target;
|
|
361
|
-
i += 1;
|
|
362
|
-
} else if (arg === "--clear") {
|
|
363
|
-
setAction(out, "clear");
|
|
364
|
-
} else if (arg === "--yes") {
|
|
365
|
-
out.yes = true;
|
|
366
|
-
} else if (arg === "--category") {
|
|
367
|
-
const category = args[i + 1];
|
|
368
|
-
if (!category || category.startsWith("--")) throw new Error("--category expects a category");
|
|
369
|
-
out.category = normalizeCategory(category);
|
|
370
|
-
out.categorySpecified = true;
|
|
371
|
-
i += 1;
|
|
372
|
-
} else if (arg === "--limit") {
|
|
373
|
-
const limit = args[i + 1];
|
|
374
|
-
if (!limit || limit.startsWith("--")) throw new Error("--limit expects an integer from 1 to 100");
|
|
375
|
-
out.limit = parseLearningLimit(limit);
|
|
376
|
-
i += 1;
|
|
377
|
-
} else if (arg === "--min-evidence") {
|
|
378
|
-
const minEvidence = args[i + 1];
|
|
379
|
-
if (!minEvidence || minEvidence.startsWith("--")) throw new Error("--min-evidence expects an integer from 1 to 100");
|
|
380
|
-
out.minEvidenceCount = parseLearningMinEvidence(minEvidence);
|
|
381
|
-
i += 1;
|
|
382
|
-
} else if (arg === "--keep") {
|
|
383
|
-
const keep = args[i + 1];
|
|
384
|
-
if (!keep || keep.startsWith("--")) throw new Error("--keep expects an integer from 1 to 100");
|
|
385
|
-
out.keep = parseLearningKeep(keep);
|
|
386
|
-
i += 1;
|
|
387
|
-
} else if (arg === "--file") {
|
|
388
|
-
const filePath = args[i + 1];
|
|
389
|
-
if (!filePath || filePath.startsWith("--")) throw new Error("--file expects a path");
|
|
390
|
-
out.filePath = filePath;
|
|
391
|
-
i += 1;
|
|
392
|
-
} else if (arg === "--usage-file") {
|
|
393
|
-
const usageFilePath = args[i + 1];
|
|
394
|
-
if (!usageFilePath || usageFilePath.startsWith("--")) throw new Error("--usage-file expects a path");
|
|
395
|
-
out.usageFilePath = usageFilePath;
|
|
396
|
-
i += 1;
|
|
397
|
-
} else if (arg === "--review-file") {
|
|
398
|
-
const reviewFilePath = args[i + 1];
|
|
399
|
-
if (!reviewFilePath || reviewFilePath.startsWith("--")) throw new Error("--review-file expects a path");
|
|
400
|
-
out.reviewFilePath = reviewFilePath;
|
|
401
|
-
i += 1;
|
|
402
|
-
} else if (arg === "--backup-file") {
|
|
403
|
-
const backupFilePath = args[i + 1];
|
|
404
|
-
if (!backupFilePath || backupFilePath.startsWith("--")) throw new Error("--backup-file expects a path");
|
|
405
|
-
out.backupFilePath = backupFilePath;
|
|
406
|
-
i += 1;
|
|
407
|
-
} else if (parseBriefSourceFlag(args, out)) {
|
|
408
|
-
if (!out.action) {
|
|
409
|
-
setAction(out, "remember");
|
|
410
|
-
} else if (!["remember", "feedback", "import", "verify", "diff", "restore", "redact", "eval", "signals", "agent-backlog", "propose-skills"].includes(out.action)) {
|
|
411
|
-
setAction(out, "remember");
|
|
412
|
-
}
|
|
413
|
-
i = out.index;
|
|
414
|
-
} else if (parseOutputFlags(args, out)) {
|
|
415
|
-
i = out.index;
|
|
416
|
-
} else if (arg.startsWith("--")) {
|
|
417
|
-
throw new Error(unknownOptionMessage("learn", arg, LEARN_OPTIONS));
|
|
418
|
-
} else {
|
|
419
|
-
out.noteParts.push(arg);
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (!out.action) {
|
|
424
|
-
out.action = out.noteParts.length > 0 ? "remember" : "list";
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (!["remember", "feedback"].includes(out.action) && out.noteParts.length > 0) {
|
|
428
|
-
throw new Error(`Unexpected learn argument for --${out.action}: ${out.noteParts[0]}`);
|
|
429
|
-
}
|
|
430
|
-
if (out.outcomeSpecified && out.action !== "feedback") {
|
|
431
|
-
throw new Error("--outcome can only be used with --feedback");
|
|
432
|
-
}
|
|
433
|
-
if (out.fix && out.action !== "audit") {
|
|
434
|
-
throw new Error("--fix can only be used with --audit");
|
|
435
|
-
}
|
|
436
|
-
if (out.prune && out.action !== "restore-backups") {
|
|
437
|
-
throw new Error("--prune can only be used with --restore-backups");
|
|
438
|
-
}
|
|
439
|
-
if (out.keep && !(out.action === "restore-backups" && out.prune)) {
|
|
440
|
-
throw new Error("--keep can only be used with --restore-backups --prune");
|
|
441
|
-
}
|
|
442
|
-
if (out.dryRun && !out.fix && !["import", "init", "curate", "restore"].includes(out.action) && !(out.action === "restore-backups" && out.prune)) {
|
|
443
|
-
throw new Error("--dry-run requires --fix, --init, --import, --restore, --curate, or --restore-backups --prune");
|
|
444
|
-
}
|
|
445
|
-
if (out.fix && out.dryRun && out.yes) {
|
|
446
|
-
throw new Error("Choose either --dry-run or --yes for --audit --fix");
|
|
447
|
-
}
|
|
448
|
-
if (out.action === "import" && out.dryRun && out.yes) {
|
|
449
|
-
throw new Error("Choose either --dry-run or --yes for --import");
|
|
450
|
-
}
|
|
451
|
-
if (out.action === "restore" && out.dryRun && out.yes) {
|
|
452
|
-
throw new Error("Choose either --dry-run or --yes for --restore");
|
|
453
|
-
}
|
|
454
|
-
if (out.action === "init" && out.dryRun && out.yes) {
|
|
455
|
-
throw new Error("Choose either --dry-run or --yes for --init");
|
|
456
|
-
}
|
|
457
|
-
if (out.action === "curate" && out.dryRun && out.yes) {
|
|
458
|
-
throw new Error("Choose either --dry-run or --yes for --curate");
|
|
459
|
-
}
|
|
460
|
-
if (out.action === "restore-backups" && out.prune && out.dryRun && out.yes) {
|
|
461
|
-
throw new Error("Choose either --dry-run or --yes for --restore-backups --prune");
|
|
462
|
-
}
|
|
463
|
-
if (out.action === "restore-backups" && out.yes && !out.prune) {
|
|
464
|
-
throw new Error("--yes can only be used with --restore-backups --prune");
|
|
465
|
-
}
|
|
466
|
-
if (out.action === "feedback" && !out.categorySpecified) {
|
|
467
|
-
out.category = "workflow";
|
|
468
|
-
}
|
|
469
|
-
if (out.query && !["list", "export", "eval-template"].includes(out.action)) {
|
|
470
|
-
throw new Error("--query can only be used with --list, --export, or --eval-template");
|
|
471
|
-
}
|
|
472
|
-
if (out.explain && out.action !== "list") {
|
|
473
|
-
throw new Error("--explain can only be used with --list");
|
|
474
|
-
}
|
|
475
|
-
if (out.usageFilePath && !["usage", "curate", "signals", "agent-backlog", "propose-skills"].includes(out.action)) {
|
|
476
|
-
throw new Error("--usage-file can only be used with --usage, --curate, --signals, --agent-backlog, or --propose-skills");
|
|
477
|
-
}
|
|
478
|
-
if (out.reviewFilePath && out.action !== "propose-skills") {
|
|
479
|
-
throw new Error("--review-file can only be used with --propose-skills");
|
|
480
|
-
}
|
|
481
|
-
if (out.reviewTemplate && out.action !== "propose-skills") {
|
|
482
|
-
throw new Error("--review-template can only be used with --propose-skills");
|
|
483
|
-
}
|
|
484
|
-
if (out.reviewCheck && out.action !== "propose-skills") {
|
|
485
|
-
throw new Error("--review-check can only be used with --propose-skills");
|
|
486
|
-
}
|
|
487
|
-
if (out.applyPlan && out.action !== "propose-skills") {
|
|
488
|
-
throw new Error("--apply-plan can only be used with --propose-skills");
|
|
489
|
-
}
|
|
490
|
-
if (out.minEvidenceCount && out.action !== "propose-skills") {
|
|
491
|
-
throw new Error("--min-evidence can only be used with --propose-skills");
|
|
492
|
-
}
|
|
493
|
-
if (out.backupFilePath && out.action !== "restore") {
|
|
494
|
-
throw new Error("--backup-file can only be used with --restore");
|
|
495
|
-
}
|
|
496
|
-
if (out.report && !["curate", "signals", "agent-backlog", "propose-skills"].includes(out.action)) {
|
|
497
|
-
throw new Error("--report can only be used with --curate, --signals, --agent-backlog, or --propose-skills");
|
|
498
|
-
}
|
|
499
|
-
if (out.patch && out.action !== "propose-skills") {
|
|
500
|
-
throw new Error("--patch can only be used with --propose-skills");
|
|
501
|
-
}
|
|
502
|
-
if (out.reviewCheck && (out.patch || out.reviewTemplate)) {
|
|
503
|
-
throw new Error("--review-check cannot be combined with --patch or --review-template");
|
|
504
|
-
}
|
|
505
|
-
if (out.reviewCheck && !out.reviewFilePath) {
|
|
506
|
-
throw new Error("--review-check requires --review-file");
|
|
507
|
-
}
|
|
508
|
-
if (out.applyPlan && (out.patch || out.reviewTemplate || out.reviewCheck)) {
|
|
509
|
-
throw new Error("--apply-plan cannot be combined with --patch, --review-template, or --review-check");
|
|
510
|
-
}
|
|
511
|
-
if (out.applyPlan && !out.reviewFilePath) {
|
|
512
|
-
throw new Error("--apply-plan requires --review-file");
|
|
513
|
-
}
|
|
514
|
-
if ([out.json, out.report, out.patch, out.reviewTemplate].filter(Boolean).length > 1) {
|
|
515
|
-
throw new Error("Choose only one output mode: --json, --report, --patch, or --review-template");
|
|
516
|
-
}
|
|
517
|
-
if (out.strict && !["eval", "signals", "agent-backlog", "propose-skills"].includes(out.action)) {
|
|
518
|
-
throw new Error("--strict can only be used with --eval, --signals, --agent-backlog, or --propose-skills");
|
|
519
|
-
}
|
|
520
|
-
if (out.action === "eval" && !out.fromFile && !out.stdin) {
|
|
521
|
-
throw new Error("--eval requires --from-file or --stdin");
|
|
522
|
-
}
|
|
523
|
-
if (out.action === "signals" && out.stdin) {
|
|
524
|
-
throw new Error("--signals does not support --stdin; use --from-file for a signal file or directory");
|
|
525
|
-
}
|
|
526
|
-
if (out.action === "agent-backlog" && out.stdin) {
|
|
527
|
-
throw new Error("--agent-backlog does not support --stdin; use --from-file for a signal file or directory");
|
|
528
|
-
}
|
|
529
|
-
if (out.action === "agent-backlog" && out.yes) {
|
|
530
|
-
throw new Error("--agent-backlog is read-only and does not accept --yes");
|
|
531
|
-
}
|
|
532
|
-
if (out.action === "propose-skills" && out.stdin) {
|
|
533
|
-
throw new Error("--propose-skills does not support --stdin; use --from-file for a signal file or directory");
|
|
534
|
-
}
|
|
535
|
-
if (out.action === "propose-skills" && out.yes) {
|
|
536
|
-
throw new Error("--propose-skills is preview-only and does not accept --yes");
|
|
537
|
-
}
|
|
538
|
-
if (out.action === "diff" && !out.fromFile && !out.stdin) {
|
|
539
|
-
throw new Error("--diff requires --from-file or --stdin");
|
|
540
|
-
}
|
|
541
|
-
if (out.action === "restore" && !out.fromFile && !out.stdin) {
|
|
542
|
-
throw new Error("--restore requires --from-file or --stdin");
|
|
543
|
-
}
|
|
544
|
-
const allowsMarkdownOut = ["export", "eval-template"].includes(out.action)
|
|
545
|
-
|| (out.action === "curate" && out.report)
|
|
546
|
-
|| (out.action === "signals" && out.report)
|
|
547
|
-
|| (out.action === "agent-backlog" && out.report)
|
|
548
|
-
|| (out.action === "propose-skills" && (out.report || out.patch || out.reviewTemplate));
|
|
549
|
-
if (!out.help && out.outPath && !allowsMarkdownOut && !out.json) {
|
|
550
|
-
throw new Error("--out requires --json for learn actions other than --export, --eval-template, --curate --report, --signals --report, --agent-backlog --report, --propose-skills --report, --propose-skills --patch, or --propose-skills --review-template");
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const resolvedFilePath = path.resolve(out.filePath || defaultLearningFile());
|
|
554
|
-
return {
|
|
555
|
-
...out,
|
|
556
|
-
index: undefined,
|
|
557
|
-
briefParts: out.noteParts,
|
|
558
|
-
filePath: resolvedFilePath,
|
|
559
|
-
usageFilePath: path.resolve(out.usageFilePath || defaultLearningUsageFile(resolvedFilePath)),
|
|
560
|
-
reviewFilePath: out.reviewFilePath ? path.resolve(out.reviewFilePath) : "",
|
|
561
|
-
backupFilePath: out.backupFilePath ? path.resolve(out.backupFilePath) : "",
|
|
562
|
-
category: normalizeCategory(out.category),
|
|
563
|
-
feedbackOutcome: normalizeFeedbackOutcome(out.feedbackOutcome),
|
|
564
|
-
query: out.query,
|
|
565
|
-
brief: out.noteParts.join(" ").trim(),
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
export function emptyLearningProfile() {
|
|
570
|
-
return {
|
|
571
|
-
version: 1,
|
|
572
|
-
updatedAt: "",
|
|
573
|
-
entries: [],
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function normalizeEntry(entry) {
|
|
578
|
-
if (!entry || typeof entry !== "object") return null;
|
|
579
|
-
const text = String(entry.text || "").trim();
|
|
580
|
-
if (!text) return null;
|
|
581
|
-
const createdAt = String(entry.createdAt || "").trim();
|
|
582
|
-
return {
|
|
583
|
-
id: String(entry.id || `learn-${shortEntryId({ text, category: entry.category || "preference", createdAt })}`).trim(),
|
|
584
|
-
category: normalizeCategory(entry.category || "preference"),
|
|
585
|
-
text,
|
|
586
|
-
source: String(entry.source || "cli").trim() || "cli",
|
|
587
|
-
createdAt,
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
export function normalizeLearningProfile(rawProfile) {
|
|
592
|
-
const profile = rawProfile && typeof rawProfile === "object" ? rawProfile : {};
|
|
593
|
-
const entries = Array.isArray(profile.entries)
|
|
594
|
-
? profile.entries.map(normalizeEntry).filter(Boolean)
|
|
595
|
-
: [];
|
|
596
|
-
|
|
597
|
-
return {
|
|
598
|
-
version: Number.isInteger(profile.version) ? profile.version : 1,
|
|
599
|
-
updatedAt: String(profile.updatedAt || "").trim(),
|
|
600
|
-
entries,
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export function loadLearningProfile(filePath = defaultLearningFile()) {
|
|
605
|
-
if (!existsSync(filePath)) return emptyLearningProfile();
|
|
606
|
-
const raw = readFileSync(filePath, "utf8");
|
|
607
|
-
try {
|
|
608
|
-
return normalizeLearningProfile(JSON.parse(raw));
|
|
609
|
-
} catch (error) {
|
|
610
|
-
throw new Error(`Learning profile is not valid JSON: ${filePath}`);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function learningAuditIssue({ level = "warning", code, entryId = "", message }) {
|
|
615
|
-
return {
|
|
616
|
-
level,
|
|
617
|
-
code,
|
|
618
|
-
...(entryId ? { entryId } : {}),
|
|
619
|
-
message,
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function summarizeLearningAudit(issues) {
|
|
624
|
-
const failures = issues.filter((issue) => issue.level === "failure").length;
|
|
625
|
-
const warnings = issues.filter((issue) => issue.level === "warning").length;
|
|
626
|
-
return {
|
|
627
|
-
status: failures > 0 ? "fail" : warnings > 0 ? "warn" : "pass",
|
|
628
|
-
failures,
|
|
629
|
-
warnings,
|
|
630
|
-
};
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function shellQuote(value) {
|
|
634
|
-
const text = String(value);
|
|
635
|
-
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(text)) return text;
|
|
636
|
-
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function commandFromArgs(args) {
|
|
640
|
-
return args.map(shellQuote).join(" ");
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
function forgetCommand({ filePath, entryId }) {
|
|
644
|
-
return ["design-ai", "learn", "--file", filePath, "--forget", entryId, "--yes"];
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function commandSuggestion({ issue, action, message, commandArgs = [] }) {
|
|
648
|
-
return {
|
|
649
|
-
issueCode: issue.code,
|
|
650
|
-
...(issue.entryId ? { entryId: issue.entryId } : {}),
|
|
651
|
-
action,
|
|
652
|
-
message,
|
|
653
|
-
...(commandArgs.length > 0 ? {
|
|
654
|
-
commandArgs,
|
|
655
|
-
command: commandFromArgs(commandArgs),
|
|
656
|
-
} : {}),
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
function learningAuditSuggestion(issue, filePath, { ambiguousEntryIds = new Set() } = {}) {
|
|
661
|
-
const hasStableEntryTarget = issue.entryId && ![
|
|
662
|
-
"missing-entry-id",
|
|
663
|
-
"duplicate-entry-id",
|
|
664
|
-
"invalid-entry",
|
|
665
|
-
"empty-entry-text",
|
|
666
|
-
"invalid-category",
|
|
667
|
-
].includes(issue.code) && !ambiguousEntryIds.has(issue.entryId);
|
|
668
|
-
const commandArgs = hasStableEntryTarget
|
|
669
|
-
? forgetCommand({ filePath, entryId: issue.entryId })
|
|
670
|
-
: [];
|
|
671
|
-
|
|
672
|
-
if (issue.code === "duplicate-entry-text") {
|
|
673
|
-
return commandSuggestion({
|
|
674
|
-
issue,
|
|
675
|
-
action: "remove-duplicate",
|
|
676
|
-
message: "Remove the duplicate entry, or rewrite it if it captures a distinct constraint.",
|
|
677
|
-
commandArgs,
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
if (issue.code.startsWith("sensitive-")) {
|
|
682
|
-
return commandSuggestion({
|
|
683
|
-
issue,
|
|
684
|
-
action: "remove-or-redact-sensitive-content",
|
|
685
|
-
message: "Remove this entry or re-add a redacted preference before using --with-learning.",
|
|
686
|
-
commandArgs,
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
if (issue.code === "long-entry-text") {
|
|
691
|
-
return commandSuggestion({
|
|
692
|
-
issue,
|
|
693
|
-
action: "split-or-rewrite",
|
|
694
|
-
message: "Split this note into smaller preference entries, or remove and re-add a focused version.",
|
|
695
|
-
commandArgs,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (["missing-created-at", "invalid-created-at"].includes(issue.code)) {
|
|
700
|
-
return commandSuggestion({
|
|
701
|
-
issue,
|
|
702
|
-
action: "refresh-entry-metadata",
|
|
703
|
-
message: "Remove and re-add this entry if recency ordering matters for prompt personalization.",
|
|
704
|
-
commandArgs,
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (issue.code === "missing-entry-id") {
|
|
709
|
-
return commandSuggestion({
|
|
710
|
-
issue,
|
|
711
|
-
action: "re-add-for-stable-id",
|
|
712
|
-
message: "Re-add this note through `design-ai learn --remember` if you need a stable id for deletion and audit trails.",
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
if (issue.code === "duplicate-entry-id") {
|
|
717
|
-
return commandSuggestion({
|
|
718
|
-
issue,
|
|
719
|
-
action: "manual-profile-edit",
|
|
720
|
-
message: "Inspect the profile manually before deleting because duplicate ids make id-based deletion ambiguous.",
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
if (issue.level === "failure") {
|
|
725
|
-
return commandSuggestion({
|
|
726
|
-
issue,
|
|
727
|
-
action: "manual-profile-repair",
|
|
728
|
-
message: "Repair the local learning JSON manually, or clear the profile only after reviewing the file.",
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
return commandSuggestion({
|
|
733
|
-
issue,
|
|
734
|
-
action: "manual-review",
|
|
735
|
-
message: "Review this warning before exporting or injecting local learning context.",
|
|
736
|
-
commandArgs,
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function learningAuditSuggestions(issues, { filePath }) {
|
|
741
|
-
const ambiguousEntryIds = new Set(
|
|
742
|
-
issues
|
|
743
|
-
.filter((issue) => issue.code === "duplicate-entry-id" && issue.entryId)
|
|
744
|
-
.map((issue) => issue.entryId),
|
|
745
|
-
);
|
|
746
|
-
return issues.map((issue) => learningAuditSuggestion(issue, filePath, { ambiguousEntryIds }));
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function finalizeLearningAudit(payload) {
|
|
750
|
-
const summary = summarizeLearningAudit(payload.issues);
|
|
751
|
-
return {
|
|
752
|
-
...payload,
|
|
753
|
-
summary,
|
|
754
|
-
suggestions: learningAuditSuggestions(payload.issues, { filePath: payload.file }),
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function entryAuditId(entry, index) {
|
|
759
|
-
return String(entry?.id || `entry-${index + 1}`).trim() || `entry-${index + 1}`;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function inspectLearningEntry({
|
|
763
|
-
entry,
|
|
764
|
-
index,
|
|
765
|
-
issues,
|
|
766
|
-
categoryCounts,
|
|
767
|
-
seenIds,
|
|
768
|
-
seenNotes,
|
|
769
|
-
maxEntryChars,
|
|
770
|
-
}) {
|
|
771
|
-
const entryId = entryAuditId(entry, index);
|
|
772
|
-
|
|
773
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
774
|
-
issues.push(learningAuditIssue({
|
|
775
|
-
level: "failure",
|
|
776
|
-
code: "invalid-entry",
|
|
777
|
-
entryId,
|
|
778
|
-
message: "Entry must be an object with text, category, and metadata.",
|
|
779
|
-
}));
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
if (!String(entry.id || "").trim()) {
|
|
784
|
-
issues.push(learningAuditIssue({
|
|
785
|
-
code: "missing-entry-id",
|
|
786
|
-
entryId,
|
|
787
|
-
message: "Entry is missing an id; list and export will generate one at runtime.",
|
|
788
|
-
}));
|
|
789
|
-
} else if (seenIds.has(entryId)) {
|
|
790
|
-
issues.push(learningAuditIssue({
|
|
791
|
-
code: "duplicate-entry-id",
|
|
792
|
-
entryId,
|
|
793
|
-
message: `Entry id duplicates ${seenIds.get(entryId)} and can make deletion ambiguous.`,
|
|
794
|
-
}));
|
|
795
|
-
} else {
|
|
796
|
-
seenIds.set(entryId, entryId);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
const text = String(entry.text || "").trim();
|
|
800
|
-
if (!text) {
|
|
801
|
-
issues.push(learningAuditIssue({
|
|
802
|
-
level: "failure",
|
|
803
|
-
code: "empty-entry-text",
|
|
804
|
-
entryId,
|
|
805
|
-
message: "Entry text is empty and will be ignored by prompt personalization.",
|
|
806
|
-
}));
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
const rawCategory = String(entry.category || "preference").trim().toLowerCase();
|
|
811
|
-
if (!LEARNING_CATEGORIES.includes(rawCategory)) {
|
|
812
|
-
issues.push(learningAuditIssue({
|
|
813
|
-
level: "failure",
|
|
814
|
-
code: "invalid-category",
|
|
815
|
-
entryId,
|
|
816
|
-
message: `Category must be one of: ${LEARNING_CATEGORIES.join(", ")}.`,
|
|
817
|
-
}));
|
|
818
|
-
} else {
|
|
819
|
-
categoryCounts[rawCategory] = (categoryCounts[rawCategory] || 0) + 1;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
const noteKey = `${rawCategory}\n${cleanNoteText(text).toLowerCase()}`;
|
|
823
|
-
if (seenNotes.has(noteKey)) {
|
|
824
|
-
issues.push(learningAuditIssue({
|
|
825
|
-
code: "duplicate-entry-text",
|
|
826
|
-
entryId,
|
|
827
|
-
message: `Entry duplicates ${seenNotes.get(noteKey)} in the same category.`,
|
|
828
|
-
}));
|
|
829
|
-
} else {
|
|
830
|
-
seenNotes.set(noteKey, entryId);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const createdAt = String(entry.createdAt || "").trim();
|
|
834
|
-
if (!createdAt) {
|
|
835
|
-
issues.push(learningAuditIssue({
|
|
836
|
-
code: "missing-created-at",
|
|
837
|
-
entryId,
|
|
838
|
-
message: "Entry is missing createdAt metadata, so recency is unclear.",
|
|
839
|
-
}));
|
|
840
|
-
} else if (Number.isNaN(Date.parse(createdAt))) {
|
|
841
|
-
issues.push(learningAuditIssue({
|
|
842
|
-
code: "invalid-created-at",
|
|
843
|
-
entryId,
|
|
844
|
-
message: "Entry createdAt metadata is not a parseable date.",
|
|
845
|
-
}));
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
if (text.length > maxEntryChars) {
|
|
849
|
-
issues.push(learningAuditIssue({
|
|
850
|
-
code: "long-entry-text",
|
|
851
|
-
entryId,
|
|
852
|
-
message: `Entry is ${text.length} characters; keep learning notes under ${maxEntryChars} characters when possible.`,
|
|
853
|
-
}));
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
for (const sensitivePattern of LEARNING_SENSITIVE_PATTERNS) {
|
|
857
|
-
if (sensitivePattern.pattern.test(text)) {
|
|
858
|
-
issues.push(learningAuditIssue({
|
|
859
|
-
code: `sensitive-${sensitivePattern.code}`,
|
|
860
|
-
entryId,
|
|
861
|
-
message: `Entry may contain ${sensitivePattern.label}; remove or rewrite it before using --with-learning.`,
|
|
862
|
-
}));
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function auditLearningProfileObject(rawProfile, {
|
|
868
|
-
filePath = defaultLearningFile(),
|
|
869
|
-
exists = true,
|
|
870
|
-
maxEntryChars = DEFAULT_AUDIT_MAX_ENTRY_CHARS,
|
|
871
|
-
} = {}) {
|
|
872
|
-
const payload = {
|
|
873
|
-
file: filePath,
|
|
874
|
-
exists,
|
|
875
|
-
version: null,
|
|
876
|
-
updatedAt: "",
|
|
877
|
-
count: 0,
|
|
878
|
-
categoryCounts: {},
|
|
879
|
-
issues: [],
|
|
880
|
-
};
|
|
881
|
-
|
|
882
|
-
if (!payload.exists) return finalizeLearningAudit(payload);
|
|
883
|
-
|
|
884
|
-
if (!rawProfile || typeof rawProfile !== "object" || Array.isArray(rawProfile)) {
|
|
885
|
-
payload.issues.push(learningAuditIssue({
|
|
886
|
-
level: "failure",
|
|
887
|
-
code: "invalid-profile",
|
|
888
|
-
message: "Learning profile root must be a JSON object.",
|
|
889
|
-
}));
|
|
890
|
-
return finalizeLearningAudit(payload);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
payload.version = Number.isInteger(rawProfile.version) ? rawProfile.version : 1;
|
|
894
|
-
payload.updatedAt = String(rawProfile.updatedAt || "").trim();
|
|
895
|
-
|
|
896
|
-
if (rawProfile.version !== undefined && !Number.isInteger(rawProfile.version)) {
|
|
897
|
-
payload.issues.push(learningAuditIssue({
|
|
898
|
-
code: "invalid-version",
|
|
899
|
-
message: "Profile version is not an integer; version 1 will be assumed.",
|
|
900
|
-
}));
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
if (!Array.isArray(rawProfile.entries)) {
|
|
904
|
-
payload.issues.push(learningAuditIssue({
|
|
905
|
-
level: "failure",
|
|
906
|
-
code: "invalid-entries",
|
|
907
|
-
message: "Learning profile entries must be an array.",
|
|
908
|
-
}));
|
|
909
|
-
return finalizeLearningAudit(payload);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
payload.count = rawProfile.entries.length;
|
|
913
|
-
|
|
914
|
-
const seenIds = new Map();
|
|
915
|
-
const seenNotes = new Map();
|
|
916
|
-
for (let index = 0; index < rawProfile.entries.length; index += 1) {
|
|
917
|
-
inspectLearningEntry({
|
|
918
|
-
entry: rawProfile.entries[index],
|
|
919
|
-
index,
|
|
920
|
-
issues: payload.issues,
|
|
921
|
-
categoryCounts: payload.categoryCounts,
|
|
922
|
-
seenIds,
|
|
923
|
-
seenNotes,
|
|
924
|
-
maxEntryChars,
|
|
925
|
-
});
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
payload.categoryCounts = Object.fromEntries(
|
|
929
|
-
LEARNING_CATEGORIES
|
|
930
|
-
.filter((category) => payload.categoryCounts[category])
|
|
931
|
-
.map((category) => [category, payload.categoryCounts[category]]),
|
|
932
|
-
);
|
|
933
|
-
|
|
934
|
-
return finalizeLearningAudit(payload);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
export function auditLearningProfile({
|
|
938
|
-
filePath = defaultLearningFile(),
|
|
939
|
-
maxEntryChars = DEFAULT_AUDIT_MAX_ENTRY_CHARS,
|
|
940
|
-
} = {}) {
|
|
941
|
-
if (!existsSync(filePath)) {
|
|
942
|
-
return auditLearningProfileObject(emptyLearningProfile(), {
|
|
943
|
-
filePath,
|
|
944
|
-
exists: false,
|
|
945
|
-
maxEntryChars,
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
let rawText = "";
|
|
950
|
-
try {
|
|
951
|
-
rawText = readFileSync(filePath, "utf8");
|
|
952
|
-
} catch {
|
|
953
|
-
return finalizeLearningAudit({
|
|
954
|
-
file: filePath,
|
|
955
|
-
exists: true,
|
|
956
|
-
version: null,
|
|
957
|
-
updatedAt: "",
|
|
958
|
-
count: 0,
|
|
959
|
-
categoryCounts: {},
|
|
960
|
-
issues: [
|
|
961
|
-
learningAuditIssue({
|
|
962
|
-
level: "failure",
|
|
963
|
-
code: "read-error",
|
|
964
|
-
message: "Learning profile could not be read from disk.",
|
|
965
|
-
}),
|
|
966
|
-
],
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
let rawProfile = null;
|
|
971
|
-
try {
|
|
972
|
-
rawProfile = JSON.parse(rawText);
|
|
973
|
-
} catch {
|
|
974
|
-
return finalizeLearningAudit({
|
|
975
|
-
file: filePath,
|
|
976
|
-
exists: true,
|
|
977
|
-
version: null,
|
|
978
|
-
updatedAt: "",
|
|
979
|
-
count: 0,
|
|
980
|
-
categoryCounts: {},
|
|
981
|
-
issues: [
|
|
982
|
-
learningAuditIssue({
|
|
983
|
-
level: "failure",
|
|
984
|
-
code: "invalid-json",
|
|
985
|
-
message: "Learning profile is not valid JSON.",
|
|
986
|
-
}),
|
|
987
|
-
],
|
|
988
|
-
});
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
return auditLearningProfileObject(rawProfile, {
|
|
992
|
-
filePath,
|
|
993
|
-
exists: true,
|
|
994
|
-
maxEntryChars,
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
function writeLearningProfile(filePath, profile) {
|
|
999
|
-
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1000
|
-
writeFileSync(filePath, `${JSON.stringify(profile, null, 2)}\n`, "utf8");
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
function shortEntryId({ text, category, createdAt }) {
|
|
1004
|
-
const input = `${createdAt}\n${category}\n${text}`;
|
|
1005
|
-
return createHash("sha256").update(input).digest("hex").slice(0, 10);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function shortHash(value) {
|
|
1009
|
-
return createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
function cleanNoteText(text) {
|
|
1013
|
-
return String(text || "").trim().replace(/\s+/g, " ");
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
export function rememberLearning({
|
|
1017
|
-
text,
|
|
1018
|
-
category = "preference",
|
|
1019
|
-
filePath = defaultLearningFile(),
|
|
1020
|
-
now = new Date(),
|
|
1021
|
-
source = "cli",
|
|
1022
|
-
}) {
|
|
1023
|
-
const note = cleanNoteText(text);
|
|
1024
|
-
if (!note) throw new Error("Learning note is empty");
|
|
1025
|
-
|
|
1026
|
-
const normalizedCategory = normalizeCategory(category);
|
|
1027
|
-
const createdAt = now.toISOString();
|
|
1028
|
-
const entry = {
|
|
1029
|
-
id: `learn-${shortEntryId({ text: note, category: normalizedCategory, createdAt })}`,
|
|
1030
|
-
category: normalizedCategory,
|
|
1031
|
-
text: note,
|
|
1032
|
-
source,
|
|
1033
|
-
createdAt,
|
|
1034
|
-
};
|
|
1035
|
-
const profile = loadLearningProfile(filePath);
|
|
1036
|
-
const nextProfile = {
|
|
1037
|
-
version: 1,
|
|
1038
|
-
updatedAt: createdAt,
|
|
1039
|
-
entries: [...profile.entries, entry],
|
|
1040
|
-
};
|
|
1041
|
-
|
|
1042
|
-
writeLearningProfile(filePath, nextProfile);
|
|
1043
|
-
|
|
1044
|
-
return {
|
|
1045
|
-
file: filePath,
|
|
1046
|
-
entry,
|
|
1047
|
-
profile: nextProfile,
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function feedbackInstruction({ outcome, text }) {
|
|
1052
|
-
if (outcome === "keep") return `Repeat in future outputs: ${text}`;
|
|
1053
|
-
if (outcome === "avoid") return `Avoid in future outputs: ${text}`;
|
|
1054
|
-
return `Improve future outputs by: ${text}`;
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
export function recordLearningFeedback({
|
|
1058
|
-
text,
|
|
1059
|
-
outcome = "improve",
|
|
1060
|
-
category = "workflow",
|
|
1061
|
-
filePath = defaultLearningFile(),
|
|
1062
|
-
now = new Date(),
|
|
1063
|
-
}) {
|
|
1064
|
-
const normalizedOutcome = normalizeFeedbackOutcome(outcome);
|
|
1065
|
-
const feedbackText = cleanNoteText(text);
|
|
1066
|
-
if (!feedbackText) throw new Error("Learning feedback is empty");
|
|
1067
|
-
|
|
1068
|
-
return rememberLearning({
|
|
1069
|
-
text: feedbackInstruction({ outcome: normalizedOutcome, text: feedbackText }),
|
|
1070
|
-
category,
|
|
1071
|
-
filePath,
|
|
1072
|
-
now,
|
|
1073
|
-
source: `feedback:${normalizedOutcome}`,
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
function offsetIsoDate(now, offsetMs) {
|
|
1078
|
-
const base = now instanceof Date ? now : new Date(now);
|
|
1079
|
-
return new Date(base.getTime() + offsetMs).toISOString();
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
export function captureLearningEntries({
|
|
1083
|
-
entries = [],
|
|
1084
|
-
source = "cli",
|
|
1085
|
-
filePath = defaultLearningFile(),
|
|
1086
|
-
dryRun = true,
|
|
1087
|
-
now = new Date(),
|
|
1088
|
-
} = {}) {
|
|
1089
|
-
const profile = loadLearningProfile(filePath);
|
|
1090
|
-
const existingKeys = new Set(profile.entries.map(learningEntryMergeKey));
|
|
1091
|
-
const usedIds = new Set(profile.entries.map((entry) => entry.id).filter(Boolean));
|
|
1092
|
-
const added = [];
|
|
1093
|
-
const skipped = [];
|
|
1094
|
-
|
|
1095
|
-
const candidates = entries.map((entry, index) => {
|
|
1096
|
-
const text = cleanNoteText(entry?.text);
|
|
1097
|
-
if (!text) throw new Error(`Learning capture entry ${index + 1} has empty text`);
|
|
1098
|
-
const category = normalizeCategory(entry?.category || "workflow");
|
|
1099
|
-
const createdAt = offsetIsoDate(now, index);
|
|
1100
|
-
const entrySource = String(entry?.source || source || "cli").trim() || "cli";
|
|
1101
|
-
return {
|
|
1102
|
-
id: `learn-${shortEntryId({ text, category, createdAt })}`,
|
|
1103
|
-
category,
|
|
1104
|
-
text,
|
|
1105
|
-
source: entrySource,
|
|
1106
|
-
createdAt,
|
|
1107
|
-
};
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
for (const candidate of candidates) {
|
|
1111
|
-
const mergeKey = learningEntryMergeKey(candidate);
|
|
1112
|
-
if (existingKeys.has(mergeKey)) {
|
|
1113
|
-
skipped.push({
|
|
1114
|
-
...statsEntry(candidate),
|
|
1115
|
-
reason: "duplicate-entry-text",
|
|
1116
|
-
});
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
const entry = {
|
|
1121
|
-
...candidate,
|
|
1122
|
-
id: uniqueImportedEntryId(candidate, usedIds),
|
|
1123
|
-
};
|
|
1124
|
-
usedIds.add(entry.id);
|
|
1125
|
-
existingKeys.add(mergeKey);
|
|
1126
|
-
added.push(entry);
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
const updatedAt = added.length > 0 ? added[added.length - 1].createdAt : profile.updatedAt;
|
|
1130
|
-
const nextProfile = {
|
|
1131
|
-
version: 1,
|
|
1132
|
-
updatedAt,
|
|
1133
|
-
entries: [...profile.entries, ...added],
|
|
1134
|
-
};
|
|
1135
|
-
|
|
1136
|
-
if (!dryRun && added.length > 0) {
|
|
1137
|
-
writeLearningProfile(filePath, nextProfile);
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
return {
|
|
1141
|
-
file: filePath,
|
|
1142
|
-
dryRun,
|
|
1143
|
-
applied: !dryRun,
|
|
1144
|
-
source,
|
|
1145
|
-
candidateCount: candidates.length,
|
|
1146
|
-
addedCount: added.length,
|
|
1147
|
-
skippedCount: skipped.length,
|
|
1148
|
-
count: nextProfile.entries.length,
|
|
1149
|
-
entries: added,
|
|
1150
|
-
skipped,
|
|
1151
|
-
};
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
export function initializeLearningProfile({
|
|
1155
|
-
filePath = defaultLearningFile(),
|
|
1156
|
-
dryRun = true,
|
|
1157
|
-
now = new Date(),
|
|
1158
|
-
} = {}) {
|
|
1159
|
-
return captureLearningEntries({
|
|
1160
|
-
entries: LEARNING_INIT_ENTRIES,
|
|
1161
|
-
source: LEARNING_INIT_SOURCE,
|
|
1162
|
-
filePath,
|
|
1163
|
-
dryRun,
|
|
1164
|
-
now,
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
function learningEntryMergeKey(entry) {
|
|
1169
|
-
return `${entry.category}\n${cleanNoteText(entry.text).toLowerCase()}`;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
function normalizeImportedLearningEntry(entry, index, now) {
|
|
1173
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
1174
|
-
throw new Error(`Learning import entry ${index + 1} must be an object`);
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
const text = cleanNoteText(entry.text);
|
|
1178
|
-
if (!text) throw new Error(`Learning import entry ${index + 1} has empty text`);
|
|
1179
|
-
|
|
1180
|
-
const category = normalizeCategory(entry.category || "preference");
|
|
1181
|
-
const rawCreatedAt = String(entry.createdAt || "").trim();
|
|
1182
|
-
const createdAt = rawCreatedAt && !Number.isNaN(Date.parse(rawCreatedAt))
|
|
1183
|
-
? rawCreatedAt
|
|
1184
|
-
: now.toISOString();
|
|
1185
|
-
const rawSource = String(entry.source || "cli").trim() || "cli";
|
|
1186
|
-
const source = rawSource.startsWith("import") ? rawSource : `import:${rawSource}`;
|
|
1187
|
-
const rawId = String(entry.id || "").trim();
|
|
1188
|
-
|
|
1189
|
-
return {
|
|
1190
|
-
id: rawId || `learn-${shortEntryId({ text, category, createdAt })}`,
|
|
1191
|
-
category,
|
|
1192
|
-
text,
|
|
1193
|
-
source,
|
|
1194
|
-
createdAt,
|
|
1195
|
-
};
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
function uniqueImportedEntryId(entry, usedIds) {
|
|
1199
|
-
if (entry.id && !usedIds.has(entry.id)) return entry.id;
|
|
1200
|
-
|
|
1201
|
-
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
|
1202
|
-
const suffix = attempt === 0 ? "" : `:${attempt}`;
|
|
1203
|
-
const candidate = `learn-${shortEntryId({
|
|
1204
|
-
text: entry.text,
|
|
1205
|
-
category: entry.category,
|
|
1206
|
-
createdAt: `${entry.createdAt}:${entry.source}${suffix}`,
|
|
1207
|
-
})}`;
|
|
1208
|
-
if (!usedIds.has(candidate)) return candidate;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
throw new Error("Could not allocate a unique learning entry id during import");
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
function parseLearningProfilePayload(importText, label = "Learning import") {
|
|
1215
|
-
let payload = null;
|
|
1216
|
-
try {
|
|
1217
|
-
payload = JSON.parse(importText);
|
|
1218
|
-
} catch (error) {
|
|
1219
|
-
throw new Error(`${label} is not valid JSON`);
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1223
|
-
throw new Error(`${label} must be a JSON object with an entries array`);
|
|
1224
|
-
}
|
|
1225
|
-
if (!Array.isArray(payload.entries)) {
|
|
1226
|
-
throw new Error(`${label} must include an entries array`);
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
return payload;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
function parseLearningImportEntries(importText, now) {
|
|
1233
|
-
const payload = parseLearningProfilePayload(importText, "Learning import");
|
|
1234
|
-
if (payload.entries.length === 0) {
|
|
1235
|
-
throw new Error("Learning import has no entries");
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
return payload.entries.map((entry, index) => normalizeImportedLearningEntry(entry, index, now));
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
export function importLearningProfile({
|
|
1242
|
-
importText,
|
|
1243
|
-
filePath = defaultLearningFile(),
|
|
1244
|
-
dryRun = true,
|
|
1245
|
-
now = new Date(),
|
|
1246
|
-
} = {}) {
|
|
1247
|
-
const importedEntries = parseLearningImportEntries(String(importText || ""), now);
|
|
1248
|
-
const profile = loadLearningProfile(filePath);
|
|
1249
|
-
const existingKeys = new Set(profile.entries.map(learningEntryMergeKey));
|
|
1250
|
-
const usedIds = new Set(profile.entries.map((entry) => entry.id).filter(Boolean));
|
|
1251
|
-
const added = [];
|
|
1252
|
-
const skipped = [];
|
|
1253
|
-
|
|
1254
|
-
for (const importedEntry of importedEntries) {
|
|
1255
|
-
const mergeKey = learningEntryMergeKey(importedEntry);
|
|
1256
|
-
if (existingKeys.has(mergeKey)) {
|
|
1257
|
-
skipped.push({
|
|
1258
|
-
...statsEntry(importedEntry),
|
|
1259
|
-
reason: "duplicate-entry-text",
|
|
1260
|
-
});
|
|
1261
|
-
continue;
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
const entry = {
|
|
1265
|
-
...importedEntry,
|
|
1266
|
-
id: uniqueImportedEntryId(importedEntry, usedIds),
|
|
1267
|
-
};
|
|
1268
|
-
usedIds.add(entry.id);
|
|
1269
|
-
existingKeys.add(mergeKey);
|
|
1270
|
-
added.push(entry);
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
const updatedAt = added.length > 0 ? now.toISOString() : profile.updatedAt;
|
|
1274
|
-
const nextProfile = {
|
|
1275
|
-
version: 1,
|
|
1276
|
-
updatedAt,
|
|
1277
|
-
entries: [...profile.entries, ...added],
|
|
1278
|
-
};
|
|
1279
|
-
|
|
1280
|
-
if (!dryRun && added.length > 0) {
|
|
1281
|
-
writeLearningProfile(filePath, nextProfile);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
return {
|
|
1285
|
-
file: filePath,
|
|
1286
|
-
dryRun,
|
|
1287
|
-
applied: !dryRun,
|
|
1288
|
-
importedCount: importedEntries.length,
|
|
1289
|
-
addedCount: added.length,
|
|
1290
|
-
skippedCount: skipped.length,
|
|
1291
|
-
added: added.map(statsEntry),
|
|
1292
|
-
skipped,
|
|
1293
|
-
count: nextProfile.entries.length,
|
|
1294
|
-
profile: nextProfile,
|
|
1295
|
-
};
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
export function verifyLearningImportPayload({
|
|
1299
|
-
importText,
|
|
1300
|
-
source = "input",
|
|
1301
|
-
now = new Date(),
|
|
1302
|
-
} = {}) {
|
|
1303
|
-
const importedEntries = parseLearningImportEntries(String(importText || ""), now);
|
|
1304
|
-
const profile = {
|
|
1305
|
-
version: 1,
|
|
1306
|
-
updatedAt: "",
|
|
1307
|
-
entries: importedEntries,
|
|
1308
|
-
};
|
|
1309
|
-
const audit = auditLearningProfileObject(profile, {
|
|
1310
|
-
filePath: source,
|
|
1311
|
-
exists: true,
|
|
1312
|
-
});
|
|
1313
|
-
|
|
1314
|
-
return {
|
|
1315
|
-
source,
|
|
1316
|
-
importable: audit.summary.failures === 0,
|
|
1317
|
-
count: importedEntries.length,
|
|
1318
|
-
auditSummary: audit.summary,
|
|
1319
|
-
issues: audit.issues,
|
|
1320
|
-
entries: importedEntries.map(statsEntry),
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
function invalidLearningBackupAudit(filePath, message) {
|
|
1325
|
-
return {
|
|
1326
|
-
file: filePath,
|
|
1327
|
-
exists: true,
|
|
1328
|
-
version: null,
|
|
1329
|
-
updatedAt: "",
|
|
1330
|
-
count: 0,
|
|
1331
|
-
categoryCounts: {},
|
|
1332
|
-
issues: [
|
|
1333
|
-
learningAuditIssue({
|
|
1334
|
-
level: "failure",
|
|
1335
|
-
code: "invalid-json",
|
|
1336
|
-
message,
|
|
1337
|
-
}),
|
|
1338
|
-
],
|
|
1339
|
-
summary: {
|
|
1340
|
-
status: "fail",
|
|
1341
|
-
failures: 1,
|
|
1342
|
-
warnings: 0,
|
|
1343
|
-
},
|
|
1344
|
-
suggestions: [],
|
|
1345
|
-
};
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
function inspectRestoreBackupFile(filePath, fileName, pattern, activeFile) {
|
|
1349
|
-
const stat = statSync(filePath);
|
|
1350
|
-
const createdAt = parseRestoreBackupCreatedAt(fileName, pattern);
|
|
1351
|
-
let rawProfile = null;
|
|
1352
|
-
let audit = null;
|
|
1353
|
-
|
|
1354
|
-
try {
|
|
1355
|
-
rawProfile = JSON.parse(readFileSync(filePath, "utf8"));
|
|
1356
|
-
audit = auditLearningProfileObject(rawProfile, {
|
|
1357
|
-
filePath,
|
|
1358
|
-
exists: true,
|
|
1359
|
-
});
|
|
1360
|
-
} catch (error) {
|
|
1361
|
-
audit = invalidLearningBackupAudit(filePath, "Rollback backup is not valid JSON.");
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
return {
|
|
1365
|
-
file: filePath,
|
|
1366
|
-
name: fileName,
|
|
1367
|
-
createdAt,
|
|
1368
|
-
modifiedAt: stat.mtime.toISOString(),
|
|
1369
|
-
sizeBytes: stat.size,
|
|
1370
|
-
updatedAt: audit.updatedAt || "",
|
|
1371
|
-
entryCount: audit.count || 0,
|
|
1372
|
-
auditSummary: audit.summary,
|
|
1373
|
-
issueCount: audit.issues.length,
|
|
1374
|
-
restorePreviewCommand: commandFromArgs([
|
|
1375
|
-
"design-ai",
|
|
1376
|
-
"learn",
|
|
1377
|
-
"--restore",
|
|
1378
|
-
"--from-file",
|
|
1379
|
-
filePath,
|
|
1380
|
-
"--file",
|
|
1381
|
-
activeFile,
|
|
1382
|
-
"--dry-run",
|
|
1383
|
-
]),
|
|
1384
|
-
};
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
export function listLearningRestoreBackups({
|
|
1388
|
-
filePath = defaultLearningFile(),
|
|
1389
|
-
limit = 10,
|
|
1390
|
-
now = new Date(),
|
|
1391
|
-
} = {}) {
|
|
1392
|
-
const resolvedFile = path.resolve(filePath);
|
|
1393
|
-
const pattern = learningRestoreBackupPattern(resolvedFile);
|
|
1394
|
-
const directoryExists = existsSync(pattern.directory);
|
|
1395
|
-
const files = directoryExists
|
|
1396
|
-
? readdirSync(pattern.directory)
|
|
1397
|
-
.filter((fileName) => fileName.startsWith(pattern.prefix) && fileName.endsWith(pattern.ext))
|
|
1398
|
-
.map((fileName) => ({
|
|
1399
|
-
fileName,
|
|
1400
|
-
filePath: path.join(pattern.directory, fileName),
|
|
1401
|
-
}))
|
|
1402
|
-
.filter(({ filePath: backupPath }) => statSync(backupPath).isFile())
|
|
1403
|
-
: [];
|
|
1404
|
-
|
|
1405
|
-
const backups = files
|
|
1406
|
-
.map(({ fileName, filePath: backupPath }) => inspectRestoreBackupFile(backupPath, fileName, pattern, resolvedFile))
|
|
1407
|
-
.sort((a, b) => {
|
|
1408
|
-
const aKey = a.createdAt || a.modifiedAt || a.name;
|
|
1409
|
-
const bKey = b.createdAt || b.modifiedAt || b.name;
|
|
1410
|
-
return bKey.localeCompare(aKey);
|
|
1411
|
-
});
|
|
1412
|
-
const limitedBackups = backups.slice(0, limit || 10);
|
|
1413
|
-
|
|
1414
|
-
return {
|
|
1415
|
-
file: resolvedFile,
|
|
1416
|
-
directory: pattern.directory,
|
|
1417
|
-
pattern: pattern.glob,
|
|
1418
|
-
generatedAt: now.toISOString(),
|
|
1419
|
-
limit: limit || 10,
|
|
1420
|
-
totalCount: backups.length,
|
|
1421
|
-
count: limitedBackups.length,
|
|
1422
|
-
backups: limitedBackups,
|
|
1423
|
-
privacy: {
|
|
1424
|
-
storesRawBriefText: false,
|
|
1425
|
-
exposesEntryTextPreview: false,
|
|
1426
|
-
mutatesProfile: false,
|
|
1427
|
-
},
|
|
1428
|
-
};
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
export function pruneLearningRestoreBackups({
|
|
1432
|
-
filePath = defaultLearningFile(),
|
|
1433
|
-
keep = 5,
|
|
1434
|
-
limit = 10,
|
|
1435
|
-
dryRun = true,
|
|
1436
|
-
now = new Date(),
|
|
1437
|
-
} = {}) {
|
|
1438
|
-
const maxKeep = Number.isInteger(keep) && keep > 0 ? keep : 5;
|
|
1439
|
-
const visibleLimit = Number.isInteger(limit) && limit > 0 ? limit : 10;
|
|
1440
|
-
const inventory = listLearningRestoreBackups({
|
|
1441
|
-
filePath,
|
|
1442
|
-
limit: Number.MAX_SAFE_INTEGER,
|
|
1443
|
-
now,
|
|
1444
|
-
});
|
|
1445
|
-
const retained = inventory.backups.slice(0, maxKeep);
|
|
1446
|
-
const candidates = inventory.backups.slice(maxKeep);
|
|
1447
|
-
const deleted = [];
|
|
1448
|
-
const failures = [];
|
|
1449
|
-
|
|
1450
|
-
if (!dryRun) {
|
|
1451
|
-
for (const backup of candidates) {
|
|
1452
|
-
try {
|
|
1453
|
-
unlinkSync(backup.file);
|
|
1454
|
-
deleted.push(backup);
|
|
1455
|
-
} catch (error) {
|
|
1456
|
-
failures.push({
|
|
1457
|
-
file: backup.file,
|
|
1458
|
-
name: backup.name,
|
|
1459
|
-
message: error instanceof Error ? error.message : String(error),
|
|
1460
|
-
});
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
return {
|
|
1466
|
-
...inventory,
|
|
1467
|
-
limit: visibleLimit,
|
|
1468
|
-
count: inventory.backups.slice(0, visibleLimit).length,
|
|
1469
|
-
backups: inventory.backups.slice(0, visibleLimit),
|
|
1470
|
-
prune: {
|
|
1471
|
-
dryRun,
|
|
1472
|
-
applied: !dryRun,
|
|
1473
|
-
keep: maxKeep,
|
|
1474
|
-
retainedCount: retained.length,
|
|
1475
|
-
candidateCount: candidates.length,
|
|
1476
|
-
deletedCount: deleted.length,
|
|
1477
|
-
failureCount: failures.length,
|
|
1478
|
-
retained,
|
|
1479
|
-
candidates,
|
|
1480
|
-
deleted,
|
|
1481
|
-
failures,
|
|
1482
|
-
},
|
|
1483
|
-
privacy: {
|
|
1484
|
-
storesRawBriefText: false,
|
|
1485
|
-
exposesEntryTextPreview: false,
|
|
1486
|
-
mutatesProfile: false,
|
|
1487
|
-
deletesBackupFiles: !dryRun && deleted.length > 0,
|
|
1488
|
-
},
|
|
1489
|
-
};
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
export function restoreLearningProfile({
|
|
1493
|
-
restoreText,
|
|
1494
|
-
filePath = defaultLearningFile(),
|
|
1495
|
-
backupFilePath = "",
|
|
1496
|
-
forceBackup = false,
|
|
1497
|
-
source = "input",
|
|
1498
|
-
dryRun = true,
|
|
1499
|
-
now = new Date(),
|
|
1500
|
-
} = {}) {
|
|
1501
|
-
const resolvedFile = path.resolve(filePath);
|
|
1502
|
-
const resolvedSource = source === "stdin" ? "stdin" : String(source || "input");
|
|
1503
|
-
const generatedAt = now.toISOString();
|
|
1504
|
-
const resolvedBackupFile = path.resolve(backupFilePath || defaultLearningRestoreBackupFile(resolvedFile, now));
|
|
1505
|
-
const rawRestore = parseLearningProfilePayload(String(restoreText || ""), "Learning restore");
|
|
1506
|
-
const restoreAudit = auditLearningProfileObject(rawRestore, {
|
|
1507
|
-
filePath: resolvedSource,
|
|
1508
|
-
exists: true,
|
|
1509
|
-
});
|
|
1510
|
-
const profileExists = existsSync(resolvedFile);
|
|
1511
|
-
const currentProfile = loadLearningProfile(resolvedFile);
|
|
1512
|
-
const restorable = restoreAudit.summary.failures === 0;
|
|
1513
|
-
|
|
1514
|
-
if (!dryRun && !restorable) {
|
|
1515
|
-
throw new Error("Refusing to restore learning profile with audit failures");
|
|
1516
|
-
}
|
|
1517
|
-
if (!dryRun && resolvedBackupFile === resolvedFile) {
|
|
1518
|
-
throw new Error("Learning restore backup file must be different from the active learning profile");
|
|
1519
|
-
}
|
|
1520
|
-
if (!dryRun && resolvedSource !== "stdin" && path.resolve(resolvedSource) === resolvedBackupFile) {
|
|
1521
|
-
throw new Error("Learning restore backup file must be different from the restore source");
|
|
1522
|
-
}
|
|
1523
|
-
if (!dryRun && existsSync(resolvedBackupFile) && !forceBackup) {
|
|
1524
|
-
throw new Error("Learning restore backup file already exists; pass --force to overwrite or choose another --backup-file path");
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
const restoredProfile = restorable ? normalizeLearningProfile(rawRestore) : emptyLearningProfile();
|
|
1528
|
-
const targetProfile = {
|
|
1529
|
-
version: 1,
|
|
1530
|
-
updatedAt: restoredProfile.updatedAt || now.toISOString(),
|
|
1531
|
-
entries: restoredProfile.entries,
|
|
1532
|
-
};
|
|
1533
|
-
const diff = restorable
|
|
1534
|
-
? diffLearningProfiles({
|
|
1535
|
-
filePath: resolvedFile,
|
|
1536
|
-
compareText: restoreText,
|
|
1537
|
-
source: resolvedSource,
|
|
1538
|
-
now,
|
|
1539
|
-
})
|
|
1540
|
-
: {
|
|
1541
|
-
sameTextCount: 0,
|
|
1542
|
-
profileOnlyCount: 0,
|
|
1543
|
-
comparisonOnlyCount: 0,
|
|
1544
|
-
metadataChangedCount: 0,
|
|
1545
|
-
idConflictCount: 0,
|
|
1546
|
-
profileOnly: [],
|
|
1547
|
-
comparisonOnly: [],
|
|
1548
|
-
metadataChanged: [],
|
|
1549
|
-
idConflicts: [],
|
|
1550
|
-
};
|
|
1551
|
-
|
|
1552
|
-
if (!dryRun) {
|
|
1553
|
-
writeLearningProfile(resolvedBackupFile, currentProfile);
|
|
1554
|
-
writeLearningProfile(resolvedFile, targetProfile);
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
return {
|
|
1558
|
-
file: resolvedFile,
|
|
1559
|
-
source: resolvedSource,
|
|
1560
|
-
generatedAt,
|
|
1561
|
-
dryRun,
|
|
1562
|
-
applied: !dryRun,
|
|
1563
|
-
restorable,
|
|
1564
|
-
profileExists,
|
|
1565
|
-
backupFile: resolvedBackupFile,
|
|
1566
|
-
backupCreated: !dryRun,
|
|
1567
|
-
backupEntryCount: currentProfile.entries.length,
|
|
1568
|
-
backupUpdatedAt: currentProfile.updatedAt,
|
|
1569
|
-
rollbackCommand: commandFromArgs([
|
|
1570
|
-
"design-ai",
|
|
1571
|
-
"learn",
|
|
1572
|
-
"--restore",
|
|
1573
|
-
"--from-file",
|
|
1574
|
-
resolvedBackupFile,
|
|
1575
|
-
"--file",
|
|
1576
|
-
resolvedFile,
|
|
1577
|
-
"--dry-run",
|
|
1578
|
-
]),
|
|
1579
|
-
previousUpdatedAt: currentProfile.updatedAt,
|
|
1580
|
-
restoredUpdatedAt: targetProfile.updatedAt,
|
|
1581
|
-
previousCount: currentProfile.entries.length,
|
|
1582
|
-
restoredCount: targetProfile.entries.length,
|
|
1583
|
-
removedCount: diff.profileOnlyCount,
|
|
1584
|
-
addedCount: diff.comparisonOnlyCount,
|
|
1585
|
-
sameTextCount: diff.sameTextCount,
|
|
1586
|
-
metadataChangedCount: diff.metadataChangedCount,
|
|
1587
|
-
idConflictCount: diff.idConflictCount,
|
|
1588
|
-
auditSummary: restoreAudit.summary,
|
|
1589
|
-
issues: restoreAudit.issues,
|
|
1590
|
-
diff: {
|
|
1591
|
-
profileOnlyCount: diff.profileOnlyCount,
|
|
1592
|
-
comparisonOnlyCount: diff.comparisonOnlyCount,
|
|
1593
|
-
metadataChangedCount: diff.metadataChangedCount,
|
|
1594
|
-
idConflictCount: diff.idConflictCount,
|
|
1595
|
-
profileOnly: diff.profileOnly,
|
|
1596
|
-
comparisonOnly: diff.comparisonOnly,
|
|
1597
|
-
metadataChanged: diff.metadataChanged,
|
|
1598
|
-
idConflicts: diff.idConflicts,
|
|
1599
|
-
},
|
|
1600
|
-
privacy: {
|
|
1601
|
-
storesRawBriefText: false,
|
|
1602
|
-
exposesEntryTextPreview: true,
|
|
1603
|
-
mutatesProfile: !dryRun,
|
|
1604
|
-
},
|
|
1605
|
-
};
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
function globalSensitivePattern(pattern) {
|
|
1609
|
-
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
1610
|
-
return new RegExp(pattern.source, flags);
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
function redactLearningText(text) {
|
|
1614
|
-
let redactedText = String(text || "");
|
|
1615
|
-
const codes = [];
|
|
1616
|
-
|
|
1617
|
-
for (const sensitivePattern of LEARNING_SENSITIVE_PATTERNS) {
|
|
1618
|
-
const pattern = globalSensitivePattern(sensitivePattern.pattern);
|
|
1619
|
-
if (!pattern.test(redactedText)) continue;
|
|
1620
|
-
|
|
1621
|
-
codes.push(`sensitive-${sensitivePattern.code}`);
|
|
1622
|
-
redactedText = redactedText.replace(pattern, `[REDACTED:${sensitivePattern.code}]`);
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
return {
|
|
1626
|
-
text: redactedText,
|
|
1627
|
-
codes,
|
|
1628
|
-
redacted: codes.length > 0,
|
|
1629
|
-
};
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
function buildRedactedLearningPayload({
|
|
1633
|
-
profile,
|
|
1634
|
-
source,
|
|
1635
|
-
sourceAudit,
|
|
1636
|
-
now = new Date(),
|
|
1637
|
-
}) {
|
|
1638
|
-
const redactions = [];
|
|
1639
|
-
const entries = profile.entries.map((entry) => {
|
|
1640
|
-
const redacted = redactLearningText(entry.text);
|
|
1641
|
-
if (!redacted.redacted) return entry;
|
|
1642
|
-
|
|
1643
|
-
const nextEntry = {
|
|
1644
|
-
...entry,
|
|
1645
|
-
text: redacted.text,
|
|
1646
|
-
};
|
|
1647
|
-
redactions.push({
|
|
1648
|
-
entryId: entry.id,
|
|
1649
|
-
category: entry.category,
|
|
1650
|
-
codes: redacted.codes,
|
|
1651
|
-
textPreview: previewText(nextEntry.text),
|
|
1652
|
-
});
|
|
1653
|
-
return nextEntry;
|
|
1654
|
-
});
|
|
1655
|
-
const redactedProfile = {
|
|
1656
|
-
version: profile.version,
|
|
1657
|
-
updatedAt: profile.updatedAt,
|
|
1658
|
-
entries,
|
|
1659
|
-
};
|
|
1660
|
-
const redactedAudit = auditLearningProfileObject(redactedProfile, {
|
|
1661
|
-
filePath: source,
|
|
1662
|
-
exists: sourceAudit.exists,
|
|
1663
|
-
});
|
|
1664
|
-
|
|
1665
|
-
return {
|
|
1666
|
-
file: source,
|
|
1667
|
-
version: profile.version,
|
|
1668
|
-
updatedAt: profile.updatedAt,
|
|
1669
|
-
exportedAt: now.toISOString(),
|
|
1670
|
-
redacted: true,
|
|
1671
|
-
count: entries.length,
|
|
1672
|
-
redactedCount: redactions.length,
|
|
1673
|
-
sourceAuditSummary: sourceAudit.summary,
|
|
1674
|
-
auditSummary: redactedAudit.summary,
|
|
1675
|
-
redactions,
|
|
1676
|
-
entries,
|
|
1677
|
-
};
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
export function buildRedactedLearningBackup({
|
|
1681
|
-
filePath = defaultLearningFile(),
|
|
1682
|
-
importText,
|
|
1683
|
-
source = "",
|
|
1684
|
-
now = new Date(),
|
|
1685
|
-
} = {}) {
|
|
1686
|
-
if (importText !== undefined) {
|
|
1687
|
-
const sourceLabel = source || "input";
|
|
1688
|
-
const rawProfile = parseLearningProfilePayload(String(importText || ""), "Learning redaction input");
|
|
1689
|
-
const profile = normalizeLearningProfile(rawProfile);
|
|
1690
|
-
const sourceAudit = auditLearningProfileObject(rawProfile, {
|
|
1691
|
-
filePath: sourceLabel,
|
|
1692
|
-
exists: true,
|
|
1693
|
-
});
|
|
1694
|
-
|
|
1695
|
-
return buildRedactedLearningPayload({
|
|
1696
|
-
profile,
|
|
1697
|
-
source: sourceLabel,
|
|
1698
|
-
sourceAudit,
|
|
1699
|
-
now,
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
|
|
1703
|
-
const sourceAudit = auditLearningProfile({ filePath });
|
|
1704
|
-
const profile = loadLearningProfile(filePath);
|
|
1705
|
-
|
|
1706
|
-
return buildRedactedLearningPayload({
|
|
1707
|
-
profile,
|
|
1708
|
-
source: filePath,
|
|
1709
|
-
sourceAudit,
|
|
1710
|
-
now,
|
|
1711
|
-
});
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
function resolveLearningEntryTarget(profile, target) {
|
|
1715
|
-
const normalizedTarget = String(target || "").trim();
|
|
1716
|
-
if (!normalizedTarget) throw new Error("--forget expects an entry id or list number");
|
|
1717
|
-
|
|
1718
|
-
if (/^\d+$/.test(normalizedTarget)) {
|
|
1719
|
-
const index = Number(normalizedTarget) - 1;
|
|
1720
|
-
if (index >= 0 && index < profile.entries.length) {
|
|
1721
|
-
return { index, entry: profile.entries[index] };
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
const index = profile.entries.findIndex((entry) => entry.id === normalizedTarget);
|
|
1726
|
-
if (index >= 0) return { index, entry: profile.entries[index] };
|
|
1727
|
-
|
|
1728
|
-
throw new Error(`Learning entry not found: ${normalizedTarget}`);
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
export function forgetLearning({
|
|
1732
|
-
target,
|
|
1733
|
-
filePath = defaultLearningFile(),
|
|
1734
|
-
now = new Date(),
|
|
1735
|
-
}) {
|
|
1736
|
-
const profile = loadLearningProfile(filePath);
|
|
1737
|
-
const { index, entry } = resolveLearningEntryTarget(profile, target);
|
|
1738
|
-
const updatedAt = now.toISOString();
|
|
1739
|
-
const nextProfile = {
|
|
1740
|
-
version: 1,
|
|
1741
|
-
updatedAt,
|
|
1742
|
-
entries: profile.entries.filter((_, entryIndex) => entryIndex !== index),
|
|
1743
|
-
};
|
|
1744
|
-
|
|
1745
|
-
writeLearningProfile(filePath, nextProfile);
|
|
1746
|
-
|
|
1747
|
-
return {
|
|
1748
|
-
file: filePath,
|
|
1749
|
-
removed: entry,
|
|
1750
|
-
count: nextProfile.entries.length,
|
|
1751
|
-
profile: nextProfile,
|
|
1752
|
-
};
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
function fixableLearningSuggestions(audit) {
|
|
1756
|
-
const byEntryId = new Map();
|
|
1757
|
-
const skipped = [];
|
|
1758
|
-
|
|
1759
|
-
for (const suggestion of audit.suggestions || []) {
|
|
1760
|
-
if (!suggestion.entryId || !Array.isArray(suggestion.commandArgs) || suggestion.commandArgs.length === 0) {
|
|
1761
|
-
skipped.push({
|
|
1762
|
-
issueCode: suggestion.issueCode,
|
|
1763
|
-
...(suggestion.entryId ? { entryId: suggestion.entryId } : {}),
|
|
1764
|
-
action: suggestion.action,
|
|
1765
|
-
reason: "manual-review-required",
|
|
1766
|
-
message: suggestion.message,
|
|
1767
|
-
});
|
|
1768
|
-
continue;
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
|
-
if (!byEntryId.has(suggestion.entryId)) {
|
|
1772
|
-
byEntryId.set(suggestion.entryId, {
|
|
1773
|
-
entryId: suggestion.entryId,
|
|
1774
|
-
issueCodes: [],
|
|
1775
|
-
actions: [],
|
|
1776
|
-
commandArgs: suggestion.commandArgs,
|
|
1777
|
-
command: suggestion.command,
|
|
1778
|
-
});
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
const fix = byEntryId.get(suggestion.entryId);
|
|
1782
|
-
if (!fix.issueCodes.includes(suggestion.issueCode)) fix.issueCodes.push(suggestion.issueCode);
|
|
1783
|
-
if (!fix.actions.includes(suggestion.action)) fix.actions.push(suggestion.action);
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
return {
|
|
1787
|
-
fixes: [...byEntryId.values()],
|
|
1788
|
-
skipped,
|
|
1789
|
-
};
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
export function applyLearningAuditFixes({
|
|
1793
|
-
filePath = defaultLearningFile(),
|
|
1794
|
-
dryRun = true,
|
|
1795
|
-
now = new Date(),
|
|
1796
|
-
} = {}) {
|
|
1797
|
-
const beforeAudit = auditLearningProfile({ filePath });
|
|
1798
|
-
const { fixes, skipped } = fixableLearningSuggestions(beforeAudit);
|
|
1799
|
-
const payload = {
|
|
1800
|
-
file: filePath,
|
|
1801
|
-
dryRun,
|
|
1802
|
-
applied: !dryRun,
|
|
1803
|
-
before: beforeAudit.summary,
|
|
1804
|
-
cleanupCount: fixes.length,
|
|
1805
|
-
cleanup: fixes,
|
|
1806
|
-
skipped,
|
|
1807
|
-
removed: [],
|
|
1808
|
-
after: null,
|
|
1809
|
-
};
|
|
1810
|
-
|
|
1811
|
-
if (dryRun || fixes.length === 0) {
|
|
1812
|
-
return payload;
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
const targetIds = new Set(fixes.map((fix) => fix.entryId));
|
|
1816
|
-
const profile = loadLearningProfile(filePath);
|
|
1817
|
-
const removed = [];
|
|
1818
|
-
const remaining = [];
|
|
1819
|
-
|
|
1820
|
-
for (const entry of profile.entries) {
|
|
1821
|
-
if (targetIds.has(entry.id)) {
|
|
1822
|
-
removed.push(statsEntry(entry));
|
|
1823
|
-
} else {
|
|
1824
|
-
remaining.push(entry);
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
const removedIds = new Set(removed.map((entry) => entry.id));
|
|
1829
|
-
for (const fix of fixes) {
|
|
1830
|
-
if (!removedIds.has(fix.entryId)) {
|
|
1831
|
-
skipped.push({
|
|
1832
|
-
entryId: fix.entryId,
|
|
1833
|
-
action: fix.actions.join(","),
|
|
1834
|
-
reason: "entry-not-found",
|
|
1835
|
-
message: "The entry was not present when applying audit cleanup.",
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
const updatedAt = now.toISOString();
|
|
1841
|
-
writeLearningProfile(filePath, {
|
|
1842
|
-
version: 1,
|
|
1843
|
-
updatedAt,
|
|
1844
|
-
entries: remaining,
|
|
1845
|
-
});
|
|
1846
|
-
|
|
1847
|
-
const afterAudit = auditLearningProfile({ filePath });
|
|
1848
|
-
return {
|
|
1849
|
-
...payload,
|
|
1850
|
-
cleanupCount: removed.length,
|
|
1851
|
-
cleanup: fixes.filter((fix) => removedIds.has(fix.entryId)),
|
|
1852
|
-
skipped,
|
|
1853
|
-
removed,
|
|
1854
|
-
after: afterAudit.summary,
|
|
1855
|
-
};
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
export function defaultLearningArchiveFile(filePath = defaultLearningFile()) {
|
|
1859
|
-
const parsed = path.parse(filePath);
|
|
1860
|
-
const ext = parsed.ext || ".json";
|
|
1861
|
-
const base = parsed.ext ? parsed.name : parsed.base;
|
|
1862
|
-
return path.join(parsed.dir || ".", `${base}.archive${ext}`);
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
export function emptyLearningArchive(sourceFile = defaultLearningFile()) {
|
|
1866
|
-
return {
|
|
1867
|
-
version: 1,
|
|
1868
|
-
updatedAt: "",
|
|
1869
|
-
sourceFile,
|
|
1870
|
-
entries: [],
|
|
1871
|
-
};
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
function normalizeLearningArchive(rawArchive, { sourceFile = defaultLearningFile() } = {}) {
|
|
1875
|
-
const archive = emptyLearningArchive(sourceFile);
|
|
1876
|
-
if (!rawArchive || typeof rawArchive !== "object" || Array.isArray(rawArchive)) {
|
|
1877
|
-
return archive;
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
archive.version = Number.isInteger(rawArchive.version) ? rawArchive.version : 1;
|
|
1881
|
-
archive.updatedAt = String(rawArchive.updatedAt || "").trim();
|
|
1882
|
-
archive.sourceFile = String(rawArchive.sourceFile || sourceFile).trim() || sourceFile;
|
|
1883
|
-
archive.entries = Array.isArray(rawArchive.entries)
|
|
1884
|
-
? rawArchive.entries
|
|
1885
|
-
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
|
|
1886
|
-
.map((entry) => {
|
|
1887
|
-
let category = "other";
|
|
1888
|
-
try {
|
|
1889
|
-
category = normalizeCategory(entry.category || "other");
|
|
1890
|
-
} catch {
|
|
1891
|
-
category = "other";
|
|
1892
|
-
}
|
|
1893
|
-
return {
|
|
1894
|
-
id: String(entry.id || "").trim(),
|
|
1895
|
-
category,
|
|
1896
|
-
text: String(entry.text || "").trim(),
|
|
1897
|
-
source: String(entry.source || "archive").trim() || "archive",
|
|
1898
|
-
createdAt: String(entry.createdAt || "").trim(),
|
|
1899
|
-
archivedAt: String(entry.archivedAt || "").trim(),
|
|
1900
|
-
archiveReason: String(entry.archiveReason || "curation").trim() || "curation",
|
|
1901
|
-
issueCodes: Array.isArray(entry.issueCodes)
|
|
1902
|
-
? entry.issueCodes.map((code) => String(code || "").trim()).filter(Boolean)
|
|
1903
|
-
: [],
|
|
1904
|
-
originalFile: String(entry.originalFile || sourceFile).trim() || sourceFile,
|
|
1905
|
-
};
|
|
1906
|
-
})
|
|
1907
|
-
.filter((entry) => entry.id && entry.text)
|
|
1908
|
-
: [];
|
|
1909
|
-
return archive;
|
|
1910
|
-
}
|
|
1911
|
-
|
|
1912
|
-
export function loadLearningArchive(archiveFile = defaultLearningArchiveFile(), {
|
|
1913
|
-
sourceFile = defaultLearningFile(),
|
|
1914
|
-
} = {}) {
|
|
1915
|
-
if (!existsSync(archiveFile)) return emptyLearningArchive(sourceFile);
|
|
1916
|
-
const raw = readFileSync(archiveFile, "utf8");
|
|
1917
|
-
try {
|
|
1918
|
-
return normalizeLearningArchive(JSON.parse(raw), { sourceFile });
|
|
1919
|
-
} catch {
|
|
1920
|
-
throw new Error(`Learning archive is not valid JSON: ${archiveFile}`);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
function writeLearningArchive(archiveFile, archive) {
|
|
1925
|
-
mkdirSync(path.dirname(archiveFile), { recursive: true });
|
|
1926
|
-
writeFileSync(archiveFile, `${JSON.stringify(archive, null, 2)}\n`, "utf8");
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
function learningCurationIssueAction(issue, { ambiguousEntryIds = new Set() } = {}) {
|
|
1930
|
-
if (!issue.entryId) {
|
|
1931
|
-
return {
|
|
1932
|
-
action: "manual-review",
|
|
1933
|
-
reason: "profile-level-issue",
|
|
1934
|
-
};
|
|
1935
|
-
}
|
|
1936
|
-
if (ambiguousEntryIds.has(issue.entryId)) {
|
|
1937
|
-
return {
|
|
1938
|
-
action: "manual-review",
|
|
1939
|
-
reason: "ambiguous-entry-id",
|
|
1940
|
-
};
|
|
1941
|
-
}
|
|
1942
|
-
if (issue.code === "duplicate-entry-text") {
|
|
1943
|
-
return {
|
|
1944
|
-
action: "archive",
|
|
1945
|
-
reason: "duplicate-entry",
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
if (issue.code.startsWith("sensitive-")) {
|
|
1949
|
-
return {
|
|
1950
|
-
action: "archive",
|
|
1951
|
-
reason: "sensitive-content",
|
|
1952
|
-
};
|
|
1953
|
-
}
|
|
1954
|
-
return {
|
|
1955
|
-
action: "manual-review",
|
|
1956
|
-
reason: issue.level === "failure" ? "manual-profile-repair" : "manual-quality-review",
|
|
1957
|
-
};
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
function issueIsHigherPriority(nextAction, currentAction) {
|
|
1961
|
-
if (!currentAction) return true;
|
|
1962
|
-
if (nextAction.action === "archive" && currentAction.action !== "archive") return true;
|
|
1963
|
-
return false;
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
function buildCurationProposal({ entry, issue, issueAction, existing }) {
|
|
1967
|
-
const currentAction = existing
|
|
1968
|
-
? { action: existing.action, reason: existing.reason }
|
|
1969
|
-
: null;
|
|
1970
|
-
const nextAction = issueIsHigherPriority(issueAction, currentAction)
|
|
1971
|
-
? issueAction
|
|
1972
|
-
: currentAction;
|
|
1973
|
-
const issueCodes = existing?.issueCodes || [];
|
|
1974
|
-
if (!issueCodes.includes(issue.code)) issueCodes.push(issue.code);
|
|
1975
|
-
|
|
1976
|
-
const messages = existing?.messages || [];
|
|
1977
|
-
if (issue.message && !messages.includes(issue.message)) messages.push(issue.message);
|
|
1978
|
-
|
|
1979
|
-
return {
|
|
1980
|
-
entryId: issue.entryId || "",
|
|
1981
|
-
action: nextAction.action,
|
|
1982
|
-
reason: nextAction.reason,
|
|
1983
|
-
issueCodes,
|
|
1984
|
-
messages,
|
|
1985
|
-
...(entry ? {
|
|
1986
|
-
category: entry.category,
|
|
1987
|
-
source: entry.source,
|
|
1988
|
-
createdAt: entry.createdAt,
|
|
1989
|
-
textPreview: previewText(entry.text),
|
|
1990
|
-
} : {}),
|
|
1991
|
-
};
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
function learningUsageReviewItem({ level, action, reason, entryId, usageCount = 0, entry = null, message }) {
|
|
1995
|
-
return {
|
|
1996
|
-
level,
|
|
1997
|
-
action,
|
|
1998
|
-
reason,
|
|
1999
|
-
entryId,
|
|
2000
|
-
usageCount,
|
|
2001
|
-
message,
|
|
2002
|
-
...(entry ? {
|
|
2003
|
-
category: entry.category,
|
|
2004
|
-
source: entry.source,
|
|
2005
|
-
createdAt: entry.createdAt,
|
|
2006
|
-
textPreview: previewText(entry.text),
|
|
2007
|
-
} : {}),
|
|
2008
|
-
};
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
function emptyLearningUsageCurationReview({
|
|
2012
|
-
filePath,
|
|
2013
|
-
usageFile,
|
|
2014
|
-
exists = false,
|
|
2015
|
-
profileFile = "",
|
|
2016
|
-
profileFileMatches = true,
|
|
2017
|
-
error = "",
|
|
2018
|
-
}) {
|
|
2019
|
-
return {
|
|
2020
|
-
file: path.resolve(filePath),
|
|
2021
|
-
usageFile: path.resolve(usageFile),
|
|
2022
|
-
profileFile: profileFile ? path.resolve(profileFile) : path.resolve(filePath),
|
|
2023
|
-
profileFileMatches,
|
|
2024
|
-
exists,
|
|
2025
|
-
eventCount: 0,
|
|
2026
|
-
usedEntryCount: 0,
|
|
2027
|
-
unusedEntryCount: 0,
|
|
2028
|
-
staleSelectedEntryCount: 0,
|
|
2029
|
-
reviewCount: 0,
|
|
2030
|
-
unusedReviewCount: 0,
|
|
2031
|
-
staleReviewCount: 0,
|
|
2032
|
-
reviews: [],
|
|
2033
|
-
recommendations: [],
|
|
2034
|
-
error,
|
|
2035
|
-
privacy: {
|
|
2036
|
-
storesRawBriefText: false,
|
|
2037
|
-
storesBriefHash: true,
|
|
2038
|
-
storesSelectedEntryIds: true,
|
|
2039
|
-
},
|
|
2040
|
-
autoArchive: false,
|
|
2041
|
-
};
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
function learningUsageCurationReview({
|
|
2045
|
-
filePath = defaultLearningFile(),
|
|
2046
|
-
usageFile = defaultLearningUsageFile(filePath),
|
|
2047
|
-
} = {}) {
|
|
2048
|
-
const resolvedFile = path.resolve(filePath);
|
|
2049
|
-
const resolvedUsageFile = path.resolve(usageFile);
|
|
2050
|
-
|
|
2051
|
-
try {
|
|
2052
|
-
const stats = learningUsageStats({
|
|
2053
|
-
filePath: resolvedFile,
|
|
2054
|
-
usageFile: resolvedUsageFile,
|
|
2055
|
-
limit: 10,
|
|
2056
|
-
});
|
|
2057
|
-
const statsProfileFile = stats.profileFile ? path.resolve(stats.profileFile) : resolvedFile;
|
|
2058
|
-
const profileFileMatches = statsProfileFile === resolvedFile;
|
|
2059
|
-
|
|
2060
|
-
if (!stats.exists) {
|
|
2061
|
-
return {
|
|
2062
|
-
...emptyLearningUsageCurationReview({
|
|
2063
|
-
filePath: resolvedFile,
|
|
2064
|
-
usageFile: resolvedUsageFile,
|
|
2065
|
-
profileFile: statsProfileFile,
|
|
2066
|
-
profileFileMatches,
|
|
2067
|
-
exists: false,
|
|
2068
|
-
}),
|
|
2069
|
-
recommendations: stats.recommendations || [],
|
|
2070
|
-
};
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
const profile = loadLearningProfile(resolvedFile);
|
|
2074
|
-
const entriesById = new Map(profile.entries.map((entry) => [entry.id, entry]));
|
|
2075
|
-
const reviews = [];
|
|
2076
|
-
const recommendations = [...(stats.recommendations || [])];
|
|
2077
|
-
|
|
2078
|
-
if (!profileFileMatches) {
|
|
2079
|
-
reviews.push(learningUsageReviewItem({
|
|
2080
|
-
level: "warning",
|
|
2081
|
-
action: "review-usage-sidecar",
|
|
2082
|
-
reason: "usage-profile-file-mismatch",
|
|
2083
|
-
entryId: "",
|
|
2084
|
-
usageCount: stats.eventCount,
|
|
2085
|
-
message: "Usage sidecar was recorded for a different learning profile path.",
|
|
2086
|
-
}));
|
|
2087
|
-
recommendations.push({
|
|
2088
|
-
level: "warning",
|
|
2089
|
-
text: "Usage sidecar profile path differs from the active learning profile.",
|
|
2090
|
-
});
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
for (const entryId of stats.staleSelectedEntryIds || []) {
|
|
2094
|
-
reviews.push(learningUsageReviewItem({
|
|
2095
|
-
level: "warning",
|
|
2096
|
-
action: "review-usage-sidecar",
|
|
2097
|
-
reason: "stale-selected-entry-id",
|
|
2098
|
-
entryId,
|
|
2099
|
-
usageCount: stats.selectedEntryCounts?.[entryId] || 0,
|
|
2100
|
-
message: "Usage sidecar selected an entry id that is no longer present in the active learning profile.",
|
|
2101
|
-
}));
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
if (stats.eventCount > 0) {
|
|
2105
|
-
for (const entryId of stats.unusedEntryIds || []) {
|
|
2106
|
-
const entry = entriesById.get(entryId);
|
|
2107
|
-
if (!entry) continue;
|
|
2108
|
-
reviews.push(learningUsageReviewItem({
|
|
2109
|
-
level: "info",
|
|
2110
|
-
action: "manual-review",
|
|
2111
|
-
reason: stats.eventCount >= 5
|
|
2112
|
-
? "unused-in-observed-usage"
|
|
2113
|
-
: "unused-with-limited-history",
|
|
2114
|
-
entryId,
|
|
2115
|
-
entry,
|
|
2116
|
-
message: "Active entry has not been selected in recorded prompt/pack usage; review manually before archiving.",
|
|
2117
|
-
}));
|
|
2118
|
-
}
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
const unusedReviewCount = reviews.filter((review) => review.reason.startsWith("unused-")).length;
|
|
2122
|
-
const staleReviewCount = reviews.filter((review) => review.reason === "stale-selected-entry-id").length;
|
|
2123
|
-
|
|
2124
|
-
return {
|
|
2125
|
-
file: stats.file,
|
|
2126
|
-
usageFile: stats.usageFile,
|
|
2127
|
-
profileFile: statsProfileFile,
|
|
2128
|
-
profileFileMatches,
|
|
2129
|
-
exists: true,
|
|
2130
|
-
eventCount: stats.eventCount,
|
|
2131
|
-
usedEntryCount: stats.usedEntryCount,
|
|
2132
|
-
unusedEntryCount: stats.unusedEntryCount,
|
|
2133
|
-
staleSelectedEntryCount: stats.staleSelectedEntryCount,
|
|
2134
|
-
reviewCount: reviews.length,
|
|
2135
|
-
unusedReviewCount,
|
|
2136
|
-
staleReviewCount,
|
|
2137
|
-
reviews,
|
|
2138
|
-
recommendations,
|
|
2139
|
-
error: "",
|
|
2140
|
-
privacy: stats.privacy || {
|
|
2141
|
-
storesRawBriefText: false,
|
|
2142
|
-
storesBriefHash: true,
|
|
2143
|
-
storesSelectedEntryIds: true,
|
|
2144
|
-
},
|
|
2145
|
-
autoArchive: false,
|
|
2146
|
-
};
|
|
2147
|
-
} catch (error) {
|
|
2148
|
-
return emptyLearningUsageCurationReview({
|
|
2149
|
-
filePath: resolvedFile,
|
|
2150
|
-
usageFile: resolvedUsageFile,
|
|
2151
|
-
exists: existsSync(resolvedUsageFile),
|
|
2152
|
-
error: error?.message || String(error),
|
|
2153
|
-
});
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
|
-
export function buildLearningCurationPlan({
|
|
2158
|
-
filePath = defaultLearningFile(),
|
|
2159
|
-
archiveFile = defaultLearningArchiveFile(filePath),
|
|
2160
|
-
usageFile = defaultLearningUsageFile(filePath),
|
|
2161
|
-
} = {}) {
|
|
2162
|
-
const audit = auditLearningProfile({ filePath });
|
|
2163
|
-
const payload = {
|
|
2164
|
-
file: filePath,
|
|
2165
|
-
archiveFile,
|
|
2166
|
-
usage: learningUsageCurationReview({ filePath, usageFile }),
|
|
2167
|
-
before: audit.summary,
|
|
2168
|
-
proposalCount: 0,
|
|
2169
|
-
archiveCount: 0,
|
|
2170
|
-
manualReviewCount: 0,
|
|
2171
|
-
proposals: [],
|
|
2172
|
-
skipped: [],
|
|
2173
|
-
count: audit.count,
|
|
2174
|
-
};
|
|
2175
|
-
|
|
2176
|
-
if (!audit.exists) {
|
|
2177
|
-
payload.skipped.push({
|
|
2178
|
-
reason: "profile-missing",
|
|
2179
|
-
message: "No local learning profile exists yet.",
|
|
2180
|
-
});
|
|
2181
|
-
return payload;
|
|
2182
|
-
}
|
|
2183
|
-
|
|
2184
|
-
if (audit.summary.failures > 0) {
|
|
2185
|
-
payload.skipped.push({
|
|
2186
|
-
reason: "profile-has-failures",
|
|
2187
|
-
message: "Repair failing learning profile issues before automated curation.",
|
|
2188
|
-
});
|
|
2189
|
-
return payload;
|
|
2190
|
-
}
|
|
2191
|
-
|
|
2192
|
-
const profile = loadLearningProfile(filePath);
|
|
2193
|
-
const entriesById = new Map(profile.entries.map((entry) => [entry.id, entry]));
|
|
2194
|
-
const ambiguousEntryIds = new Set(
|
|
2195
|
-
audit.issues
|
|
2196
|
-
.filter((issue) => issue.code === "duplicate-entry-id" && issue.entryId)
|
|
2197
|
-
.map((issue) => issue.entryId),
|
|
2198
|
-
);
|
|
2199
|
-
const proposalsByEntryId = new Map();
|
|
2200
|
-
|
|
2201
|
-
for (const issue of audit.issues) {
|
|
2202
|
-
const issueAction = learningCurationIssueAction(issue, { ambiguousEntryIds });
|
|
2203
|
-
const entry = issue.entryId ? entriesById.get(issue.entryId) : null;
|
|
2204
|
-
if (issue.entryId && !entry) {
|
|
2205
|
-
payload.skipped.push({
|
|
2206
|
-
entryId: issue.entryId,
|
|
2207
|
-
issueCode: issue.code,
|
|
2208
|
-
reason: "entry-not-found",
|
|
2209
|
-
message: "The audited entry was not present in the normalized learning profile.",
|
|
2210
|
-
});
|
|
2211
|
-
continue;
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
const key = issue.entryId || `profile:${issue.code}`;
|
|
2215
|
-
const proposal = buildCurationProposal({
|
|
2216
|
-
entry,
|
|
2217
|
-
issue,
|
|
2218
|
-
issueAction,
|
|
2219
|
-
existing: proposalsByEntryId.get(key),
|
|
2220
|
-
});
|
|
2221
|
-
proposalsByEntryId.set(key, proposal);
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
payload.proposals = [...proposalsByEntryId.values()];
|
|
2225
|
-
payload.proposalCount = payload.proposals.length;
|
|
2226
|
-
payload.archiveCount = payload.proposals.filter((proposal) => proposal.action === "archive").length;
|
|
2227
|
-
payload.manualReviewCount = payload.proposals.filter((proposal) => proposal.action === "manual-review").length;
|
|
2228
|
-
return payload;
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
export function applyLearningCurationPlan({
|
|
2232
|
-
filePath = defaultLearningFile(),
|
|
2233
|
-
archiveFile = defaultLearningArchiveFile(filePath),
|
|
2234
|
-
usageFile = defaultLearningUsageFile(filePath),
|
|
2235
|
-
dryRun = true,
|
|
2236
|
-
now = new Date(),
|
|
2237
|
-
} = {}) {
|
|
2238
|
-
const plan = buildLearningCurationPlan({ filePath, archiveFile, usageFile });
|
|
2239
|
-
const payload = {
|
|
2240
|
-
...plan,
|
|
2241
|
-
dryRun,
|
|
2242
|
-
applied: !dryRun,
|
|
2243
|
-
archived: [],
|
|
2244
|
-
after: null,
|
|
2245
|
-
};
|
|
2246
|
-
|
|
2247
|
-
if (dryRun || plan.archiveCount === 0) {
|
|
2248
|
-
return payload;
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
const archiveIds = new Set(
|
|
2252
|
-
plan.proposals
|
|
2253
|
-
.filter((proposal) => proposal.action === "archive")
|
|
2254
|
-
.map((proposal) => proposal.entryId),
|
|
2255
|
-
);
|
|
2256
|
-
const proposalByEntryId = new Map(plan.proposals.map((proposal) => [proposal.entryId, proposal]));
|
|
2257
|
-
const profile = loadLearningProfile(filePath);
|
|
2258
|
-
const archived = [];
|
|
2259
|
-
const remaining = [];
|
|
2260
|
-
const archivedAt = now.toISOString();
|
|
2261
|
-
|
|
2262
|
-
for (const entry of profile.entries) {
|
|
2263
|
-
if (!archiveIds.has(entry.id)) {
|
|
2264
|
-
remaining.push(entry);
|
|
2265
|
-
continue;
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
const proposal = proposalByEntryId.get(entry.id);
|
|
2269
|
-
archived.push({
|
|
2270
|
-
id: entry.id,
|
|
2271
|
-
category: entry.category,
|
|
2272
|
-
text: entry.text,
|
|
2273
|
-
source: entry.source,
|
|
2274
|
-
createdAt: entry.createdAt,
|
|
2275
|
-
archivedAt,
|
|
2276
|
-
archiveReason: proposal?.reason || "curation",
|
|
2277
|
-
issueCodes: proposal?.issueCodes || [],
|
|
2278
|
-
originalFile: filePath,
|
|
2279
|
-
});
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
|
-
const archivedIds = new Set(archived.map((entry) => entry.id));
|
|
2283
|
-
for (const entryId of archiveIds) {
|
|
2284
|
-
if (!archivedIds.has(entryId)) {
|
|
2285
|
-
payload.skipped.push({
|
|
2286
|
-
entryId,
|
|
2287
|
-
reason: "entry-not-found",
|
|
2288
|
-
message: "The entry was not present when applying learning curation.",
|
|
2289
|
-
});
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
const updatedAt = now.toISOString();
|
|
2294
|
-
writeLearningProfile(filePath, {
|
|
2295
|
-
version: 1,
|
|
2296
|
-
updatedAt,
|
|
2297
|
-
entries: remaining,
|
|
2298
|
-
});
|
|
2299
|
-
|
|
2300
|
-
if (archived.length > 0) {
|
|
2301
|
-
const archive = loadLearningArchive(archiveFile, { sourceFile: filePath });
|
|
2302
|
-
writeLearningArchive(archiveFile, {
|
|
2303
|
-
version: 1,
|
|
2304
|
-
updatedAt,
|
|
2305
|
-
sourceFile: filePath,
|
|
2306
|
-
entries: [...archive.entries, ...archived],
|
|
2307
|
-
});
|
|
2308
|
-
}
|
|
2309
|
-
|
|
2310
|
-
const afterAudit = auditLearningProfile({ filePath });
|
|
2311
|
-
return {
|
|
2312
|
-
...payload,
|
|
2313
|
-
archiveCount: archived.length,
|
|
2314
|
-
archived,
|
|
2315
|
-
after: afterAudit.summary,
|
|
2316
|
-
count: remaining.length,
|
|
2317
|
-
};
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
|
-
export function clearLearning({
|
|
2321
|
-
filePath = defaultLearningFile(),
|
|
2322
|
-
now = new Date(),
|
|
2323
|
-
} = {}) {
|
|
2324
|
-
const profile = loadLearningProfile(filePath);
|
|
2325
|
-
const updatedAt = now.toISOString();
|
|
2326
|
-
const nextProfile = {
|
|
2327
|
-
version: 1,
|
|
2328
|
-
updatedAt,
|
|
2329
|
-
entries: [],
|
|
2330
|
-
};
|
|
2331
|
-
|
|
2332
|
-
writeLearningProfile(filePath, nextProfile);
|
|
2333
|
-
|
|
2334
|
-
return {
|
|
2335
|
-
file: filePath,
|
|
2336
|
-
removedCount: profile.entries.length,
|
|
2337
|
-
profile: nextProfile,
|
|
2338
|
-
};
|
|
2339
|
-
}
|
|
2340
|
-
|
|
2341
|
-
function learningQueryTokens(query) {
|
|
2342
|
-
return Array.from(new Set(
|
|
2343
|
-
String(query || "")
|
|
2344
|
-
.toLowerCase()
|
|
2345
|
-
.match(/[\p{L}\p{N}]+/gu) || [],
|
|
2346
|
-
)).filter((token) => token.length >= 2);
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
function learningEntryRelevance(entry, queryTokens) {
|
|
2350
|
-
if (queryTokens.length === 0) {
|
|
2351
|
-
return { score: 0, matchedTokens: [] };
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
const text = cleanNoteText(`${entry.category || ""} ${entry.text || ""}`).toLowerCase();
|
|
2355
|
-
if (!text) return { score: 0, matchedTokens: [] };
|
|
2356
|
-
|
|
2357
|
-
let score = 0;
|
|
2358
|
-
const matchedTokens = [];
|
|
2359
|
-
for (const token of queryTokens) {
|
|
2360
|
-
if (text.includes(token)) {
|
|
2361
|
-
score += token.length >= 4 ? 2 : 1;
|
|
2362
|
-
matchedTokens.push(token);
|
|
2363
|
-
}
|
|
2364
|
-
}
|
|
2365
|
-
return { score, matchedTokens };
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
function learningEntryTime(entry) {
|
|
2369
|
-
const time = Date.parse(entry.createdAt || "");
|
|
2370
|
-
return Number.isNaN(time) ? 0 : time;
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
function rankLearningEntries(entries, { query = "" } = {}) {
|
|
2374
|
-
const queryTokens = learningQueryTokens(query);
|
|
2375
|
-
const ranked = entries.map((entry, index) => {
|
|
2376
|
-
const relevance = learningEntryRelevance(entry, queryTokens);
|
|
2377
|
-
return {
|
|
2378
|
-
entry,
|
|
2379
|
-
index,
|
|
2380
|
-
score: relevance.score,
|
|
2381
|
-
matchedTokens: relevance.matchedTokens,
|
|
2382
|
-
time: learningEntryTime(entry),
|
|
2383
|
-
};
|
|
2384
|
-
});
|
|
2385
|
-
|
|
2386
|
-
if (queryTokens.length === 0) {
|
|
2387
|
-
return {
|
|
2388
|
-
entries,
|
|
2389
|
-
ranked,
|
|
2390
|
-
query: "",
|
|
2391
|
-
mode: "recency",
|
|
2392
|
-
candidateCount: entries.length,
|
|
2393
|
-
matchedCount: 0,
|
|
2394
|
-
queryTokenCount: 0,
|
|
2395
|
-
};
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
ranked.sort((a, b) => (
|
|
2399
|
-
b.score - a.score
|
|
2400
|
-
|| b.time - a.time
|
|
2401
|
-
|| b.index - a.index
|
|
2402
|
-
));
|
|
2403
|
-
|
|
2404
|
-
return {
|
|
2405
|
-
entries: ranked.map((item) => item.entry),
|
|
2406
|
-
ranked,
|
|
2407
|
-
query: String(query || "").trim(),
|
|
2408
|
-
mode: "brief-relevance",
|
|
2409
|
-
candidateCount: entries.length,
|
|
2410
|
-
matchedCount: ranked.filter((item) => item.score > 0).length,
|
|
2411
|
-
queryTokenCount: queryTokens.length,
|
|
2412
|
-
};
|
|
2413
|
-
}
|
|
2414
|
-
|
|
2415
|
-
function learningSelectionReason(item, mode) {
|
|
2416
|
-
if (mode !== "brief-relevance") return "recency";
|
|
2417
|
-
return item.score > 0 ? "brief-match" : "recency-fallback";
|
|
2418
|
-
}
|
|
2419
|
-
|
|
2420
|
-
function learningSelectionItem(item, mode) {
|
|
2421
|
-
return {
|
|
2422
|
-
id: item.entry.id,
|
|
2423
|
-
category: item.entry.category,
|
|
2424
|
-
score: item.score,
|
|
2425
|
-
matchedTokens: item.matchedTokens,
|
|
2426
|
-
reason: learningSelectionReason(item, mode),
|
|
2427
|
-
};
|
|
2428
|
-
}
|
|
2429
|
-
|
|
2430
|
-
export function selectLearningEntrySet(profile, {
|
|
2431
|
-
category = "",
|
|
2432
|
-
limit = 0,
|
|
2433
|
-
query = "",
|
|
2434
|
-
includeFallback = true,
|
|
2435
|
-
} = {}) {
|
|
2436
|
-
const normalizedCategory = category ? normalizeCategory(category) : "";
|
|
2437
|
-
const entries = [...profile.entries].filter((entry) => (
|
|
2438
|
-
entry.text && (!normalizedCategory || entry.category === normalizedCategory)
|
|
2439
|
-
));
|
|
2440
|
-
const ranked = rankLearningEntries(entries, { query });
|
|
2441
|
-
const rankedItems = ranked.mode === "brief-relevance" && !includeFallback
|
|
2442
|
-
? ranked.ranked.filter((item) => item.score > 0)
|
|
2443
|
-
: ranked.ranked;
|
|
2444
|
-
const selectedItems = Number.isInteger(limit) && limit > 0
|
|
2445
|
-
? ranked.mode === "brief-relevance"
|
|
2446
|
-
? rankedItems.slice(0, limit)
|
|
2447
|
-
: rankedItems.slice(-limit)
|
|
2448
|
-
: rankedItems;
|
|
2449
|
-
const selected = selectedItems.map((item) => item.entry);
|
|
2450
|
-
|
|
2451
|
-
return {
|
|
2452
|
-
entries: selected,
|
|
2453
|
-
selection: {
|
|
2454
|
-
mode: ranked.mode,
|
|
2455
|
-
query: ranked.query,
|
|
2456
|
-
candidateCount: ranked.candidateCount,
|
|
2457
|
-
matchedCount: ranked.matchedCount,
|
|
2458
|
-
queryTokenCount: ranked.queryTokenCount,
|
|
2459
|
-
fallbackEnabled: ranked.mode === "brief-relevance" ? includeFallback : false,
|
|
2460
|
-
selectedCount: selected.length,
|
|
2461
|
-
fallbackCount: ranked.mode === "brief-relevance"
|
|
2462
|
-
? selectedItems.filter((item) => item.score === 0).length
|
|
2463
|
-
: 0,
|
|
2464
|
-
selected: selectedItems.map((item) => learningSelectionItem(item, ranked.mode)),
|
|
2465
|
-
},
|
|
2466
|
-
};
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
|
-
export function selectLearningEntries(profile, {
|
|
2470
|
-
category = "",
|
|
2471
|
-
limit = 0,
|
|
2472
|
-
query = "",
|
|
2473
|
-
includeFallback = true,
|
|
2474
|
-
} = {}) {
|
|
2475
|
-
return selectLearningEntrySet(profile, {
|
|
2476
|
-
category,
|
|
2477
|
-
limit,
|
|
2478
|
-
query,
|
|
2479
|
-
includeFallback,
|
|
2480
|
-
}).entries;
|
|
2481
|
-
}
|
|
2482
|
-
|
|
2483
|
-
export function recentLearningEntries(profile, limit = 12, options = {}) {
|
|
2484
|
-
return selectLearningEntries(profile, {
|
|
2485
|
-
...options,
|
|
2486
|
-
limit,
|
|
2487
|
-
});
|
|
2488
|
-
}
|
|
2489
|
-
|
|
2490
|
-
function learningAuditNotice(auditSummary) {
|
|
2491
|
-
if (!auditSummary || auditSummary.status === "pass") return "";
|
|
2492
|
-
return `Learning profile audit: ${auditSummary.status} (${auditSummary.failures} failure(s), ${auditSummary.warnings} warning(s)). Run \`design-ai learn --audit\` before relying on this context.`;
|
|
2493
|
-
}
|
|
2494
|
-
|
|
2495
|
-
function learningSelectionNotice(selection) {
|
|
2496
|
-
if (!selection || selection.mode !== "brief-relevance") return "";
|
|
2497
|
-
const fallback = selection.fallbackEnabled
|
|
2498
|
-
? "recency fallback for ties"
|
|
2499
|
-
: "no recency fallback";
|
|
2500
|
-
return `Learning selection: brief relevance (${selection.matchedCount}/${selection.candidateCount} matched; ${fallback}).`;
|
|
2501
|
-
}
|
|
2502
|
-
|
|
2503
|
-
export function renderLearningMarkdown(profile, {
|
|
2504
|
-
limit = 12,
|
|
2505
|
-
category = "",
|
|
2506
|
-
query = "",
|
|
2507
|
-
includeFallback = true,
|
|
2508
|
-
auditSummary = null,
|
|
2509
|
-
} = {}) {
|
|
2510
|
-
const { entries, selection } = selectLearningEntrySet(profile, {
|
|
2511
|
-
category,
|
|
2512
|
-
limit,
|
|
2513
|
-
query,
|
|
2514
|
-
includeFallback,
|
|
2515
|
-
});
|
|
2516
|
-
const lines = ["## Learned design context", ""];
|
|
2517
|
-
|
|
2518
|
-
if (entries.length === 0) {
|
|
2519
|
-
lines.push(category || query
|
|
2520
|
-
? "No local learning preferences match the current filters."
|
|
2521
|
-
: "No local learning preferences are stored yet.");
|
|
2522
|
-
return lines.join("\n");
|
|
2523
|
-
}
|
|
2524
|
-
|
|
2525
|
-
lines.push("Apply these as user/project preferences. Do not let them override explicit task instructions, accessibility requirements, or privacy constraints.");
|
|
2526
|
-
const auditNotice = learningAuditNotice(auditSummary);
|
|
2527
|
-
if (auditNotice) {
|
|
2528
|
-
lines.push(auditNotice);
|
|
2529
|
-
}
|
|
2530
|
-
const selectionNotice = learningSelectionNotice(selection);
|
|
2531
|
-
if (selectionNotice) {
|
|
2532
|
-
lines.push(selectionNotice);
|
|
2533
|
-
}
|
|
2534
|
-
lines.push("");
|
|
2535
|
-
for (const entry of entries) {
|
|
2536
|
-
lines.push(`- [${entry.category}] ${entry.text}`);
|
|
2537
|
-
}
|
|
2538
|
-
return lines.join("\n");
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
export function buildLearningContext({
|
|
2542
|
-
filePath = defaultLearningFile(),
|
|
2543
|
-
limit = 12,
|
|
2544
|
-
category = "",
|
|
2545
|
-
query = "",
|
|
2546
|
-
includeFallback = true,
|
|
2547
|
-
} = {}) {
|
|
2548
|
-
const audit = auditLearningProfile({ filePath });
|
|
2549
|
-
const profile = loadLearningProfile(filePath);
|
|
2550
|
-
const { entries, selection } = selectLearningEntrySet(profile, {
|
|
2551
|
-
category,
|
|
2552
|
-
limit,
|
|
2553
|
-
query,
|
|
2554
|
-
includeFallback,
|
|
2555
|
-
});
|
|
2556
|
-
return {
|
|
2557
|
-
file: filePath,
|
|
2558
|
-
category,
|
|
2559
|
-
limit,
|
|
2560
|
-
query: String(query || "").trim(),
|
|
2561
|
-
selection,
|
|
2562
|
-
entries,
|
|
2563
|
-
empty: entries.length === 0,
|
|
2564
|
-
auditSummary: audit.summary,
|
|
2565
|
-
markdown: renderLearningMarkdown(profile, {
|
|
2566
|
-
limit,
|
|
2567
|
-
category,
|
|
2568
|
-
query,
|
|
2569
|
-
includeFallback,
|
|
2570
|
-
auditSummary: audit.summary,
|
|
2571
|
-
}),
|
|
2572
|
-
};
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
export function emptyLearningUsageLog({ profileFile = "" } = {}) {
|
|
2576
|
-
return {
|
|
2577
|
-
version: 1,
|
|
2578
|
-
updatedAt: "",
|
|
2579
|
-
profileFile,
|
|
2580
|
-
events: [],
|
|
2581
|
-
};
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
function normalizeLearningUsageEvent(event) {
|
|
2585
|
-
if (!event || typeof event !== "object" || Array.isArray(event)) return null;
|
|
2586
|
-
const createdAt = String(event.createdAt || "").trim();
|
|
2587
|
-
const command = String(event.command || "").trim();
|
|
2588
|
-
const selectedEntryIds = Array.isArray(event.selectedEntryIds)
|
|
2589
|
-
? event.selectedEntryIds.map((id) => String(id || "").trim()).filter(Boolean)
|
|
2590
|
-
: [];
|
|
2591
|
-
|
|
2592
|
-
if (!createdAt || !command) return null;
|
|
2593
|
-
|
|
2594
|
-
return {
|
|
2595
|
-
id: String(event.id || `learn-use-${shortHash(`${createdAt}\n${command}`)}`).trim(),
|
|
2596
|
-
command,
|
|
2597
|
-
routeId: String(event.routeId || "").trim(),
|
|
2598
|
-
profileFile: String(event.profileFile || "").trim(),
|
|
2599
|
-
briefHash: String(event.briefHash || "").trim(),
|
|
2600
|
-
category: String(event.category || "").trim(),
|
|
2601
|
-
limit: Number.isInteger(event.limit) ? event.limit : null,
|
|
2602
|
-
selectedEntryIds,
|
|
2603
|
-
selectedCount: Number.isInteger(event.selectedCount) ? event.selectedCount : selectedEntryIds.length,
|
|
2604
|
-
candidateCount: Number.isInteger(event.candidateCount) ? event.candidateCount : 0,
|
|
2605
|
-
matchedCount: Number.isInteger(event.matchedCount) ? event.matchedCount : 0,
|
|
2606
|
-
fallbackCount: Number.isInteger(event.fallbackCount) ? event.fallbackCount : 0,
|
|
2607
|
-
queryTokenCount: Number.isInteger(event.queryTokenCount) ? event.queryTokenCount : 0,
|
|
2608
|
-
auditStatus: String(event.auditStatus || "").trim(),
|
|
2609
|
-
createdAt,
|
|
2610
|
-
};
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
export function normalizeLearningUsageLog(rawLog, { profileFile = "" } = {}) {
|
|
2614
|
-
const log = rawLog && typeof rawLog === "object" ? rawLog : {};
|
|
2615
|
-
const events = Array.isArray(log.events)
|
|
2616
|
-
? log.events.map(normalizeLearningUsageEvent).filter(Boolean)
|
|
2617
|
-
: [];
|
|
2618
|
-
|
|
2619
|
-
return {
|
|
2620
|
-
version: Number.isInteger(log.version) ? log.version : 1,
|
|
2621
|
-
updatedAt: String(log.updatedAt || "").trim(),
|
|
2622
|
-
profileFile: String(log.profileFile || profileFile || "").trim(),
|
|
2623
|
-
events,
|
|
2624
|
-
};
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
export function loadLearningUsageLog(filePath = defaultLearningUsageFile(), { profileFile = "" } = {}) {
|
|
2628
|
-
if (!existsSync(filePath)) {
|
|
2629
|
-
return emptyLearningUsageLog({ profileFile });
|
|
2630
|
-
}
|
|
2631
|
-
|
|
2632
|
-
const raw = readFileSync(filePath, "utf8");
|
|
2633
|
-
try {
|
|
2634
|
-
return normalizeLearningUsageLog(JSON.parse(raw), { profileFile });
|
|
2635
|
-
} catch {
|
|
2636
|
-
throw new Error(`Learning usage log is not valid JSON: ${filePath}`);
|
|
2637
|
-
}
|
|
2638
|
-
}
|
|
2639
|
-
|
|
2640
|
-
function writeLearningUsageLog(filePath, log) {
|
|
2641
|
-
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2642
|
-
writeFileSync(filePath, `${JSON.stringify(log, null, 2)}\n`, "utf8");
|
|
2643
|
-
}
|
|
2644
|
-
|
|
2645
|
-
export function buildLearningUsageEvent({
|
|
2646
|
-
command,
|
|
2647
|
-
routeId = "",
|
|
2648
|
-
learningContext,
|
|
2649
|
-
now = new Date(),
|
|
2650
|
-
} = {}) {
|
|
2651
|
-
if (!learningContext) return null;
|
|
2652
|
-
|
|
2653
|
-
const createdAt = now.toISOString();
|
|
2654
|
-
const selection = learningContext.selection || {};
|
|
2655
|
-
const selectedEntryIds = Array.isArray(selection.selected) && selection.selected.length > 0
|
|
2656
|
-
? selection.selected.map((item) => item?.id).filter(Boolean)
|
|
2657
|
-
: (learningContext.entries || []).map((entry) => entry.id).filter(Boolean);
|
|
2658
|
-
const normalizedCommand = String(command || "").trim();
|
|
2659
|
-
|
|
2660
|
-
return {
|
|
2661
|
-
id: `learn-use-${shortHash([
|
|
2662
|
-
createdAt,
|
|
2663
|
-
normalizedCommand,
|
|
2664
|
-
routeId,
|
|
2665
|
-
selectedEntryIds.join(","),
|
|
2666
|
-
learningContext.query || "",
|
|
2667
|
-
].join("\n"))}`,
|
|
2668
|
-
command: normalizedCommand,
|
|
2669
|
-
routeId: String(routeId || "").trim(),
|
|
2670
|
-
profileFile: String(learningContext.file || "").trim(),
|
|
2671
|
-
briefHash: shortHash(learningContext.query || ""),
|
|
2672
|
-
category: String(learningContext.category || "").trim(),
|
|
2673
|
-
limit: Number.isInteger(learningContext.limit) ? learningContext.limit : null,
|
|
2674
|
-
selectedEntryIds,
|
|
2675
|
-
selectedCount: Number.isInteger(selection.selectedCount) ? selection.selectedCount : selectedEntryIds.length,
|
|
2676
|
-
candidateCount: Number.isInteger(selection.candidateCount) ? selection.candidateCount : 0,
|
|
2677
|
-
matchedCount: Number.isInteger(selection.matchedCount) ? selection.matchedCount : 0,
|
|
2678
|
-
fallbackCount: Number.isInteger(selection.fallbackCount) ? selection.fallbackCount : 0,
|
|
2679
|
-
queryTokenCount: Number.isInteger(selection.queryTokenCount) ? selection.queryTokenCount : 0,
|
|
2680
|
-
auditStatus: String(learningContext.auditSummary?.status || "").trim(),
|
|
2681
|
-
createdAt,
|
|
2682
|
-
};
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
export function recordLearningUsage({
|
|
2686
|
-
command,
|
|
2687
|
-
routeId = "",
|
|
2688
|
-
learningContext,
|
|
2689
|
-
usageFile = defaultLearningUsageFile(learningContext?.file || defaultLearningFile()),
|
|
2690
|
-
now = new Date(),
|
|
2691
|
-
eventLimit = DEFAULT_LEARNING_USAGE_EVENT_LIMIT,
|
|
2692
|
-
} = {}) {
|
|
2693
|
-
const event = buildLearningUsageEvent({
|
|
2694
|
-
command,
|
|
2695
|
-
routeId,
|
|
2696
|
-
learningContext,
|
|
2697
|
-
now,
|
|
2698
|
-
});
|
|
2699
|
-
const resolvedUsageFile = path.resolve(usageFile);
|
|
2700
|
-
|
|
2701
|
-
if (!event) {
|
|
2702
|
-
return {
|
|
2703
|
-
file: resolvedUsageFile,
|
|
2704
|
-
recorded: false,
|
|
2705
|
-
reason: "missing-learning-context",
|
|
2706
|
-
count: 0,
|
|
2707
|
-
event: null,
|
|
2708
|
-
};
|
|
2709
|
-
}
|
|
2710
|
-
|
|
2711
|
-
const log = loadLearningUsageLog(resolvedUsageFile, { profileFile: event.profileFile });
|
|
2712
|
-
const updatedAt = event.createdAt;
|
|
2713
|
-
const maxEvents = Number.isInteger(eventLimit) && eventLimit > 0
|
|
2714
|
-
? eventLimit
|
|
2715
|
-
: DEFAULT_LEARNING_USAGE_EVENT_LIMIT;
|
|
2716
|
-
const events = [...log.events, event].slice(-maxEvents);
|
|
2717
|
-
const nextLog = {
|
|
2718
|
-
version: 1,
|
|
2719
|
-
updatedAt,
|
|
2720
|
-
profileFile: event.profileFile || log.profileFile,
|
|
2721
|
-
events,
|
|
2722
|
-
};
|
|
2723
|
-
|
|
2724
|
-
writeLearningUsageLog(resolvedUsageFile, nextLog);
|
|
2725
|
-
|
|
2726
|
-
return {
|
|
2727
|
-
file: resolvedUsageFile,
|
|
2728
|
-
recorded: true,
|
|
2729
|
-
event,
|
|
2730
|
-
count: nextLog.events.length,
|
|
2731
|
-
eventLimit: maxEvents,
|
|
2732
|
-
};
|
|
2733
|
-
}
|
|
2734
|
-
|
|
2735
|
-
export function buildLearningBackup({ filePath = defaultLearningFile(), now = new Date() } = {}) {
|
|
2736
|
-
const audit = auditLearningProfile({ filePath });
|
|
2737
|
-
const profile = loadLearningProfile(filePath);
|
|
2738
|
-
return {
|
|
2739
|
-
file: filePath,
|
|
2740
|
-
version: profile.version,
|
|
2741
|
-
updatedAt: profile.updatedAt,
|
|
2742
|
-
exportedAt: now.toISOString(),
|
|
2743
|
-
count: profile.entries.length,
|
|
2744
|
-
auditSummary: audit.summary,
|
|
2745
|
-
entries: profile.entries,
|
|
2746
|
-
};
|
|
2747
|
-
}
|
|
2748
|
-
|
|
2749
|
-
function firstEntryByMergeKey(entries) {
|
|
2750
|
-
const map = new Map();
|
|
2751
|
-
for (const entry of entries) {
|
|
2752
|
-
const key = learningEntryMergeKey(entry);
|
|
2753
|
-
if (!map.has(key)) map.set(key, entry);
|
|
2754
|
-
}
|
|
2755
|
-
return map;
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
|
-
function firstEntryById(entries) {
|
|
2759
|
-
const map = new Map();
|
|
2760
|
-
for (const entry of entries) {
|
|
2761
|
-
if (entry.id && !map.has(entry.id)) map.set(entry.id, entry);
|
|
2762
|
-
}
|
|
2763
|
-
return map;
|
|
2764
|
-
}
|
|
2765
|
-
|
|
2766
|
-
function metadataDiffFields(profileEntry, comparisonEntry) {
|
|
2767
|
-
return ["id", "source", "createdAt"].filter((field) => (
|
|
2768
|
-
String(profileEntry?.[field] || "") !== String(comparisonEntry?.[field] || "")
|
|
2769
|
-
));
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
function learningDiffItem({ key, profileEntry, comparisonEntry, changedFields }) {
|
|
2773
|
-
return {
|
|
2774
|
-
key,
|
|
2775
|
-
changedFields,
|
|
2776
|
-
profile: statsEntry(profileEntry),
|
|
2777
|
-
comparison: statsEntry(comparisonEntry),
|
|
2778
|
-
};
|
|
2779
|
-
}
|
|
2780
|
-
|
|
2781
|
-
function learningIdConflict({ id, profileEntry, comparisonEntry }) {
|
|
2782
|
-
return {
|
|
2783
|
-
id,
|
|
2784
|
-
profile: statsEntry(profileEntry),
|
|
2785
|
-
comparison: statsEntry(comparisonEntry),
|
|
2786
|
-
};
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
export function diffLearningProfiles({
|
|
2790
|
-
filePath = defaultLearningFile(),
|
|
2791
|
-
compareText = "",
|
|
2792
|
-
source = "input",
|
|
2793
|
-
now = new Date(),
|
|
2794
|
-
} = {}) {
|
|
2795
|
-
const resolvedFile = path.resolve(filePath);
|
|
2796
|
-
const resolvedSource = source === "stdin" ? "stdin" : String(source || "input");
|
|
2797
|
-
const profileExists = existsSync(resolvedFile);
|
|
2798
|
-
const profile = loadLearningProfile(resolvedFile);
|
|
2799
|
-
const profileAudit = auditLearningProfile({ filePath: resolvedFile });
|
|
2800
|
-
const rawComparison = parseLearningProfilePayload(String(compareText || ""), "Learning diff comparison");
|
|
2801
|
-
const comparison = normalizeLearningProfile(rawComparison);
|
|
2802
|
-
const comparisonAudit = auditLearningProfileObject(rawComparison, {
|
|
2803
|
-
filePath: resolvedSource,
|
|
2804
|
-
exists: true,
|
|
2805
|
-
});
|
|
2806
|
-
|
|
2807
|
-
const profileByKey = firstEntryByMergeKey(profile.entries);
|
|
2808
|
-
const comparisonByKey = firstEntryByMergeKey(comparison.entries);
|
|
2809
|
-
const profileKeys = new Set(profileByKey.keys());
|
|
2810
|
-
const comparisonKeys = new Set(comparisonByKey.keys());
|
|
2811
|
-
const commonKeys = [...profileKeys].filter((key) => comparisonKeys.has(key)).sort();
|
|
2812
|
-
const profileOnly = [...profile.entries]
|
|
2813
|
-
.filter((entry) => !comparisonKeys.has(learningEntryMergeKey(entry)))
|
|
2814
|
-
.map(statsEntry);
|
|
2815
|
-
const comparisonOnly = [...comparison.entries]
|
|
2816
|
-
.filter((entry) => !profileKeys.has(learningEntryMergeKey(entry)))
|
|
2817
|
-
.map(statsEntry);
|
|
2818
|
-
const metadataChanged = commonKeys
|
|
2819
|
-
.map((key) => {
|
|
2820
|
-
const profileEntry = profileByKey.get(key);
|
|
2821
|
-
const comparisonEntry = comparisonByKey.get(key);
|
|
2822
|
-
const changedFields = metadataDiffFields(profileEntry, comparisonEntry);
|
|
2823
|
-
return changedFields.length > 0
|
|
2824
|
-
? learningDiffItem({ key, profileEntry, comparisonEntry, changedFields })
|
|
2825
|
-
: null;
|
|
2826
|
-
})
|
|
2827
|
-
.filter(Boolean);
|
|
2828
|
-
|
|
2829
|
-
const profileById = firstEntryById(profile.entries);
|
|
2830
|
-
const comparisonById = firstEntryById(comparison.entries);
|
|
2831
|
-
const idConflicts = [...profileById.keys()]
|
|
2832
|
-
.filter((id) => comparisonById.has(id))
|
|
2833
|
-
.map((id) => {
|
|
2834
|
-
const profileEntry = profileById.get(id);
|
|
2835
|
-
const comparisonEntry = comparisonById.get(id);
|
|
2836
|
-
return learningEntryMergeKey(profileEntry) !== learningEntryMergeKey(comparisonEntry)
|
|
2837
|
-
? learningIdConflict({ id, profileEntry, comparisonEntry })
|
|
2838
|
-
: null;
|
|
2839
|
-
})
|
|
2840
|
-
.filter(Boolean);
|
|
2841
|
-
|
|
2842
|
-
const recommendations = [];
|
|
2843
|
-
if (!profileExists) {
|
|
2844
|
-
recommendations.push({
|
|
2845
|
-
level: "warning",
|
|
2846
|
-
text: "Active learning profile does not exist; comparison is against an empty local profile.",
|
|
2847
|
-
});
|
|
2848
|
-
}
|
|
2849
|
-
if (profileAudit.summary.status !== "pass") {
|
|
2850
|
-
recommendations.push({
|
|
2851
|
-
level: profileAudit.summary.failures > 0 ? "warning" : "info",
|
|
2852
|
-
text: "Run `design-ai learn --audit` on the active profile before applying import or restore decisions.",
|
|
2853
|
-
});
|
|
2854
|
-
}
|
|
2855
|
-
if (comparisonAudit.summary.status !== "pass") {
|
|
2856
|
-
recommendations.push({
|
|
2857
|
-
level: comparisonAudit.summary.failures > 0 ? "warning" : "info",
|
|
2858
|
-
text: "Review comparison profile audit issues before importing or restoring entries from it.",
|
|
2859
|
-
});
|
|
2860
|
-
}
|
|
2861
|
-
if (idConflicts.length > 0) {
|
|
2862
|
-
recommendations.push({
|
|
2863
|
-
level: "warning",
|
|
2864
|
-
text: "Matching ids with different learning text were found; inspect manually before importing or restoring.",
|
|
2865
|
-
});
|
|
2866
|
-
}
|
|
2867
|
-
if (comparisonOnly.length > 0) {
|
|
2868
|
-
recommendations.push({
|
|
2869
|
-
level: "info",
|
|
2870
|
-
text: "Run `design-ai learn --import --from-file <profile.json> --dry-run` to preview adding comparison-only entries.",
|
|
2871
|
-
});
|
|
2872
|
-
}
|
|
2873
|
-
if (profileOnly.length > 0) {
|
|
2874
|
-
recommendations.push({
|
|
2875
|
-
level: "info",
|
|
2876
|
-
text: "Profile-only entries would be absent if the comparison profile were used as the restore source.",
|
|
2877
|
-
});
|
|
2878
|
-
}
|
|
2879
|
-
if (
|
|
2880
|
-
profileOnly.length === 0
|
|
2881
|
-
&& comparisonOnly.length === 0
|
|
2882
|
-
&& metadataChanged.length === 0
|
|
2883
|
-
&& idConflicts.length === 0
|
|
2884
|
-
) {
|
|
2885
|
-
recommendations.push({
|
|
2886
|
-
level: "info",
|
|
2887
|
-
text: "No learning profile diff action is needed.",
|
|
2888
|
-
});
|
|
2889
|
-
}
|
|
2890
|
-
|
|
2891
|
-
return {
|
|
2892
|
-
file: resolvedFile,
|
|
2893
|
-
source: resolvedSource,
|
|
2894
|
-
generatedAt: now.toISOString(),
|
|
2895
|
-
profileExists,
|
|
2896
|
-
profileUpdatedAt: profile.updatedAt,
|
|
2897
|
-
comparisonUpdatedAt: comparison.updatedAt,
|
|
2898
|
-
profileCount: profile.entries.length,
|
|
2899
|
-
comparisonCount: comparison.entries.length,
|
|
2900
|
-
profileAuditSummary: profileAudit.summary,
|
|
2901
|
-
comparisonAuditSummary: comparisonAudit.summary,
|
|
2902
|
-
sameTextCount: commonKeys.length,
|
|
2903
|
-
profileOnlyCount: profileOnly.length,
|
|
2904
|
-
comparisonOnlyCount: comparisonOnly.length,
|
|
2905
|
-
metadataChangedCount: metadataChanged.length,
|
|
2906
|
-
idConflictCount: idConflicts.length,
|
|
2907
|
-
profileOnly,
|
|
2908
|
-
comparisonOnly,
|
|
2909
|
-
metadataChanged,
|
|
2910
|
-
idConflicts,
|
|
2911
|
-
recommendations,
|
|
2912
|
-
privacy: {
|
|
2913
|
-
storesRawBriefText: false,
|
|
2914
|
-
exposesEntryTextPreview: true,
|
|
2915
|
-
mutatesProfile: false,
|
|
2916
|
-
},
|
|
2917
|
-
};
|
|
2918
|
-
}
|
|
2919
|
-
|
|
2920
|
-
function countBy(items, getKey) {
|
|
2921
|
-
const counts = {};
|
|
2922
|
-
for (const item of items) {
|
|
2923
|
-
const key = String(getKey(item) || "").trim();
|
|
2924
|
-
if (key) counts[key] = (counts[key] || 0) + 1;
|
|
2925
|
-
}
|
|
2926
|
-
return counts;
|
|
2927
|
-
}
|
|
2928
|
-
|
|
2929
|
-
function previewText(text, maxChars = 96) {
|
|
2930
|
-
const cleaned = cleanNoteText(text);
|
|
2931
|
-
if (cleaned.length <= maxChars) return cleaned;
|
|
2932
|
-
return `${cleaned.slice(0, maxChars - 3)}...`;
|
|
2933
|
-
}
|
|
2934
|
-
|
|
2935
|
-
function statsEntry(entry) {
|
|
2936
|
-
return {
|
|
2937
|
-
id: entry.id,
|
|
2938
|
-
category: entry.category,
|
|
2939
|
-
source: entry.source,
|
|
2940
|
-
createdAt: entry.createdAt,
|
|
2941
|
-
textPreview: previewText(entry.text),
|
|
2942
|
-
};
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
|
-
function usageEventTime(event) {
|
|
2946
|
-
const time = Date.parse(event.createdAt);
|
|
2947
|
-
return Number.isNaN(time) ? 0 : time;
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
function usageEventSummary(event) {
|
|
2951
|
-
return {
|
|
2952
|
-
id: event.id,
|
|
2953
|
-
command: event.command,
|
|
2954
|
-
routeId: event.routeId,
|
|
2955
|
-
category: event.category,
|
|
2956
|
-
limit: event.limit,
|
|
2957
|
-
selectedEntryIds: event.selectedEntryIds,
|
|
2958
|
-
selectedCount: event.selectedCount,
|
|
2959
|
-
candidateCount: event.candidateCount,
|
|
2960
|
-
matchedCount: event.matchedCount,
|
|
2961
|
-
fallbackCount: event.fallbackCount,
|
|
2962
|
-
queryTokenCount: event.queryTokenCount,
|
|
2963
|
-
auditStatus: event.auditStatus,
|
|
2964
|
-
briefHash: event.briefHash,
|
|
2965
|
-
createdAt: event.createdAt,
|
|
2966
|
-
};
|
|
2967
|
-
}
|
|
2968
|
-
|
|
2969
|
-
function incrementUsageEntry(entryUsage, entryId, event) {
|
|
2970
|
-
if (!entryId) return;
|
|
2971
|
-
const existing = entryUsage.get(entryId) || {
|
|
2972
|
-
id: entryId,
|
|
2973
|
-
count: 0,
|
|
2974
|
-
commands: {},
|
|
2975
|
-
routes: {},
|
|
2976
|
-
latestUsedAt: "",
|
|
2977
|
-
};
|
|
2978
|
-
existing.count += 1;
|
|
2979
|
-
if (event.command) existing.commands[event.command] = (existing.commands[event.command] || 0) + 1;
|
|
2980
|
-
if (event.routeId) existing.routes[event.routeId] = (existing.routes[event.routeId] || 0) + 1;
|
|
2981
|
-
if (!existing.latestUsedAt || usageEventTime(event) >= Date.parse(existing.latestUsedAt || "1970-01-01T00:00:00.000Z")) {
|
|
2982
|
-
existing.latestUsedAt = event.createdAt;
|
|
2983
|
-
}
|
|
2984
|
-
entryUsage.set(entryId, existing);
|
|
2985
|
-
}
|
|
2986
|
-
|
|
2987
|
-
function usageEntrySummary(entry, usage) {
|
|
2988
|
-
return {
|
|
2989
|
-
id: entry.id,
|
|
2990
|
-
category: entry.category,
|
|
2991
|
-
source: entry.source,
|
|
2992
|
-
textPreview: previewText(entry.text),
|
|
2993
|
-
usageCount: usage?.count || 0,
|
|
2994
|
-
latestUsedAt: usage?.latestUsedAt || "",
|
|
2995
|
-
commands: usage?.commands || {},
|
|
2996
|
-
routes: usage?.routes || {},
|
|
2997
|
-
};
|
|
2998
|
-
}
|
|
2999
|
-
|
|
3000
|
-
function parseLearningEvalPayload(evalText, source = "input") {
|
|
3001
|
-
let payload = null;
|
|
3002
|
-
try {
|
|
3003
|
-
payload = JSON.parse(String(evalText || ""));
|
|
3004
|
-
} catch {
|
|
3005
|
-
throw new Error("Learning eval checkpoint is not valid JSON");
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
3009
|
-
throw new Error("Learning eval checkpoint must be a JSON object with a cases array");
|
|
3010
|
-
}
|
|
3011
|
-
if (!Array.isArray(payload.cases)) {
|
|
3012
|
-
throw new Error("Learning eval checkpoint must include a cases array");
|
|
3013
|
-
}
|
|
3014
|
-
if (payload.cases.length === 0) {
|
|
3015
|
-
throw new Error("Learning eval checkpoint has no cases");
|
|
3016
|
-
}
|
|
3017
|
-
|
|
3018
|
-
return {
|
|
3019
|
-
source,
|
|
3020
|
-
version: Number.isInteger(payload.version) ? payload.version : 1,
|
|
3021
|
-
generatedAt: safeIsoString(payload.generatedAt),
|
|
3022
|
-
sourceProfile: summarizeLearningEvalSourceProfile(payload.sourceProfile),
|
|
3023
|
-
cases: payload.cases,
|
|
3024
|
-
};
|
|
3025
|
-
}
|
|
3026
|
-
|
|
3027
|
-
function safeIsoString(value) {
|
|
3028
|
-
const text = String(value || "").trim();
|
|
3029
|
-
if (!text) return "";
|
|
3030
|
-
return Number.isNaN(Date.parse(text)) ? "" : text;
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
function nullableBoolean(value) {
|
|
3034
|
-
return typeof value === "boolean" ? value : null;
|
|
3035
|
-
}
|
|
3036
|
-
|
|
3037
|
-
function nullableNonNegativeInteger(value) {
|
|
3038
|
-
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
3039
|
-
}
|
|
3040
|
-
|
|
3041
|
-
function summarizeLearningEvalSourceProfile(value) {
|
|
3042
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
3043
|
-
|
|
3044
|
-
return {
|
|
3045
|
-
file: String(value.file || "").trim(),
|
|
3046
|
-
exists: nullableBoolean(value.exists),
|
|
3047
|
-
entryCount: nullableNonNegativeInteger(value.entryCount),
|
|
3048
|
-
auditStatus: ["pass", "warn", "fail"].includes(String(value.auditStatus || ""))
|
|
3049
|
-
? String(value.auditStatus)
|
|
3050
|
-
: "",
|
|
3051
|
-
category: value.category ? normalizeCategory(value.category) : "",
|
|
3052
|
-
queryPresent: Boolean(cleanNoteText(value.query)),
|
|
3053
|
-
limit: nullableNonNegativeInteger(value.limit),
|
|
3054
|
-
};
|
|
3055
|
-
}
|
|
3056
|
-
|
|
3057
|
-
function evalStringList(value, { field, caseId }) {
|
|
3058
|
-
if (value === undefined || value === null) return [];
|
|
3059
|
-
if (!Array.isArray(value)) {
|
|
3060
|
-
throw new Error(`Learning eval case ${caseId} field ${field} must be an array of ids`);
|
|
3061
|
-
}
|
|
3062
|
-
return value.map((item) => String(item || "").trim()).filter(Boolean);
|
|
3063
|
-
}
|
|
3064
|
-
|
|
3065
|
-
function evalPositiveInteger(value, { field, caseId, fallback }) {
|
|
3066
|
-
if (value === undefined || value === null || value === "") return fallback;
|
|
3067
|
-
const number = Number(value);
|
|
3068
|
-
if (!Number.isInteger(number) || number < 1 || number > 100) {
|
|
3069
|
-
throw new Error(`Learning eval case ${caseId} field ${field} must be an integer from 1 to 100`);
|
|
3070
|
-
}
|
|
3071
|
-
return number;
|
|
3072
|
-
}
|
|
3073
|
-
|
|
3074
|
-
function evalNonNegativeInteger(value, { field, caseId, fallback = 0 }) {
|
|
3075
|
-
if (value === undefined || value === null || value === "") return fallback;
|
|
3076
|
-
const number = Number(value);
|
|
3077
|
-
if (!Number.isInteger(number) || number < 0 || number > 100) {
|
|
3078
|
-
throw new Error(`Learning eval case ${caseId} field ${field} must be an integer from 0 to 100`);
|
|
3079
|
-
}
|
|
3080
|
-
return number;
|
|
3081
|
-
}
|
|
3082
|
-
|
|
3083
|
-
function normalizeLearningEvalCase(rawCase, index, {
|
|
3084
|
-
defaultLimit = 12,
|
|
3085
|
-
defaultCategory = "",
|
|
3086
|
-
} = {}) {
|
|
3087
|
-
const caseId = String(rawCase?.id || `case-${index + 1}`).trim() || `case-${index + 1}`;
|
|
3088
|
-
if (!rawCase || typeof rawCase !== "object" || Array.isArray(rawCase)) {
|
|
3089
|
-
throw new Error(`Learning eval case ${caseId} must be an object`);
|
|
3090
|
-
}
|
|
3091
|
-
|
|
3092
|
-
const brief = cleanNoteText(rawCase.brief || rawCase.query);
|
|
3093
|
-
if (!brief) {
|
|
3094
|
-
throw new Error(`Learning eval case ${caseId} requires a brief`);
|
|
3095
|
-
}
|
|
3096
|
-
|
|
3097
|
-
const category = rawCase.category !== undefined
|
|
3098
|
-
? String(rawCase.category || "").trim()
|
|
3099
|
-
: defaultCategory;
|
|
3100
|
-
|
|
3101
|
-
return {
|
|
3102
|
-
id: caseId,
|
|
3103
|
-
routeId: String(rawCase.routeId || "").trim(),
|
|
3104
|
-
brief,
|
|
3105
|
-
briefHash: shortHash(brief),
|
|
3106
|
-
category: category ? normalizeCategory(category) : "",
|
|
3107
|
-
limit: evalPositiveInteger(rawCase.limit, {
|
|
3108
|
-
field: "limit",
|
|
3109
|
-
caseId,
|
|
3110
|
-
fallback: defaultLimit,
|
|
3111
|
-
}),
|
|
3112
|
-
expectedSelectedIds: evalStringList(
|
|
3113
|
-
rawCase.expectedSelectedIds ?? rawCase.expectSelectedIds ?? rawCase.expectedEntryIds,
|
|
3114
|
-
{ field: "expectedSelectedIds", caseId },
|
|
3115
|
-
),
|
|
3116
|
-
avoidedSelectedIds: evalStringList(
|
|
3117
|
-
rawCase.avoidedSelectedIds ?? rawCase.avoidSelectedIds ?? rawCase.avoidEntryIds,
|
|
3118
|
-
{ field: "avoidedSelectedIds", caseId },
|
|
3119
|
-
),
|
|
3120
|
-
minMatchedCount: evalNonNegativeInteger(rawCase.minMatchedCount, {
|
|
3121
|
-
field: "minMatchedCount",
|
|
3122
|
-
caseId,
|
|
3123
|
-
fallback: 0,
|
|
3124
|
-
}),
|
|
3125
|
-
requireNoFallback: Boolean(rawCase.requireNoFallback),
|
|
3126
|
-
};
|
|
3127
|
-
}
|
|
3128
|
-
|
|
3129
|
-
function learningEvalIssue({ level = "warning", code, message }) {
|
|
3130
|
-
return { level, code, message };
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
function summarizeLearningEvalIssues(issues) {
|
|
3134
|
-
const failures = issues.filter((issue) => issue.level === "failure").length;
|
|
3135
|
-
const warnings = issues.filter((issue) => issue.level === "warning").length;
|
|
3136
|
-
return {
|
|
3137
|
-
status: failures > 0 ? "fail" : warnings > 0 ? "warn" : "pass",
|
|
3138
|
-
failures,
|
|
3139
|
-
warnings,
|
|
3140
|
-
};
|
|
3141
|
-
}
|
|
3142
|
-
|
|
3143
|
-
function selectedEvalEntry(item) {
|
|
3144
|
-
return {
|
|
3145
|
-
id: item.id,
|
|
3146
|
-
category: item.category,
|
|
3147
|
-
score: item.score,
|
|
3148
|
-
reason: item.reason,
|
|
3149
|
-
};
|
|
3150
|
-
}
|
|
3151
|
-
|
|
3152
|
-
function evaluateLearningCase(profile, rawCase, index, {
|
|
3153
|
-
defaultLimit = 12,
|
|
3154
|
-
defaultCategory = "",
|
|
3155
|
-
} = {}) {
|
|
3156
|
-
const evalCase = normalizeLearningEvalCase(rawCase, index, {
|
|
3157
|
-
defaultLimit,
|
|
3158
|
-
defaultCategory,
|
|
3159
|
-
});
|
|
3160
|
-
const { selection } = selectLearningEntrySet(profile, {
|
|
3161
|
-
category: evalCase.category,
|
|
3162
|
-
limit: evalCase.limit,
|
|
3163
|
-
query: evalCase.brief,
|
|
3164
|
-
includeFallback: true,
|
|
3165
|
-
});
|
|
3166
|
-
const selected = Array.isArray(selection.selected) ? selection.selected : [];
|
|
3167
|
-
const selectedEntryIds = selected.map((item) => item.id).filter(Boolean);
|
|
3168
|
-
const selectedEntryIdSet = new Set(selectedEntryIds);
|
|
3169
|
-
const profileEntryIds = new Set(profile.entries.map((entry) => entry.id).filter(Boolean));
|
|
3170
|
-
const issues = [];
|
|
3171
|
-
|
|
3172
|
-
const missingProfileExpectedIds = evalCase.expectedSelectedIds
|
|
3173
|
-
.filter((entryId) => !profileEntryIds.has(entryId));
|
|
3174
|
-
const missingExpectedIds = evalCase.expectedSelectedIds
|
|
3175
|
-
.filter((entryId) => !selectedEntryIdSet.has(entryId));
|
|
3176
|
-
const unexpectedAvoidedIds = evalCase.avoidedSelectedIds
|
|
3177
|
-
.filter((entryId) => selectedEntryIdSet.has(entryId));
|
|
3178
|
-
|
|
3179
|
-
for (const entryId of missingProfileExpectedIds) {
|
|
3180
|
-
issues.push(learningEvalIssue({
|
|
3181
|
-
level: "failure",
|
|
3182
|
-
code: "expected-entry-not-in-profile",
|
|
3183
|
-
message: `Expected entry ${entryId} is not present in the active learning profile.`,
|
|
3184
|
-
}));
|
|
3185
|
-
}
|
|
3186
|
-
if (missingExpectedIds.length > 0) {
|
|
3187
|
-
issues.push(learningEvalIssue({
|
|
3188
|
-
level: "failure",
|
|
3189
|
-
code: "expected-entry-not-selected",
|
|
3190
|
-
message: `Expected selected entries were missing: ${missingExpectedIds.join(", ")}.`,
|
|
3191
|
-
}));
|
|
3192
|
-
}
|
|
3193
|
-
if (unexpectedAvoidedIds.length > 0) {
|
|
3194
|
-
issues.push(learningEvalIssue({
|
|
3195
|
-
level: "failure",
|
|
3196
|
-
code: "avoided-entry-selected",
|
|
3197
|
-
message: `Avoided entries were selected: ${unexpectedAvoidedIds.join(", ")}.`,
|
|
3198
|
-
}));
|
|
3199
|
-
}
|
|
3200
|
-
if (selection.matchedCount < evalCase.minMatchedCount) {
|
|
3201
|
-
issues.push(learningEvalIssue({
|
|
3202
|
-
level: "failure",
|
|
3203
|
-
code: "matched-count-below-minimum",
|
|
3204
|
-
message: `Matched ${selection.matchedCount} learning entries, expected at least ${evalCase.minMatchedCount}.`,
|
|
3205
|
-
}));
|
|
3206
|
-
}
|
|
3207
|
-
if (evalCase.requireNoFallback && selection.fallbackCount > 0) {
|
|
3208
|
-
issues.push(learningEvalIssue({
|
|
3209
|
-
level: "failure",
|
|
3210
|
-
code: "fallback-selected",
|
|
3211
|
-
message: `Selected ${selection.fallbackCount} recency fallback entr${selection.fallbackCount === 1 ? "y" : "ies"}.`,
|
|
3212
|
-
}));
|
|
3213
|
-
}
|
|
3214
|
-
if (
|
|
3215
|
-
evalCase.expectedSelectedIds.length === 0
|
|
3216
|
-
&& evalCase.avoidedSelectedIds.length === 0
|
|
3217
|
-
&& evalCase.minMatchedCount === 0
|
|
3218
|
-
&& !evalCase.requireNoFallback
|
|
3219
|
-
) {
|
|
3220
|
-
issues.push(learningEvalIssue({
|
|
3221
|
-
code: "no-eval-assertions",
|
|
3222
|
-
message: "Case has no expected ids, avoided ids, minMatchedCount, or requireNoFallback assertion.",
|
|
3223
|
-
}));
|
|
3224
|
-
}
|
|
3225
|
-
|
|
3226
|
-
const summary = summarizeLearningEvalIssues(issues);
|
|
3227
|
-
|
|
3228
|
-
return {
|
|
3229
|
-
id: evalCase.id,
|
|
3230
|
-
routeId: evalCase.routeId,
|
|
3231
|
-
briefHash: evalCase.briefHash,
|
|
3232
|
-
category: evalCase.category,
|
|
3233
|
-
limit: evalCase.limit,
|
|
3234
|
-
status: summary.status,
|
|
3235
|
-
failures: summary.failures,
|
|
3236
|
-
warnings: summary.warnings,
|
|
3237
|
-
candidateCount: selection.candidateCount,
|
|
3238
|
-
matchedCount: selection.matchedCount,
|
|
3239
|
-
selectedCount: selection.selectedCount,
|
|
3240
|
-
fallbackCount: selection.fallbackCount,
|
|
3241
|
-
expectedSelectedIds: evalCase.expectedSelectedIds,
|
|
3242
|
-
missingExpectedIds,
|
|
3243
|
-
avoidedSelectedIds: evalCase.avoidedSelectedIds,
|
|
3244
|
-
unexpectedAvoidedIds,
|
|
3245
|
-
minMatchedCount: evalCase.minMatchedCount,
|
|
3246
|
-
requireNoFallback: evalCase.requireNoFallback,
|
|
3247
|
-
selectedEntryIds,
|
|
3248
|
-
selected: selected.map(selectedEvalEntry),
|
|
3249
|
-
issues,
|
|
3250
|
-
};
|
|
3251
|
-
}
|
|
3252
|
-
|
|
3253
|
-
export function learningEvalReport({
|
|
3254
|
-
filePath = defaultLearningFile(),
|
|
3255
|
-
evalText = "",
|
|
3256
|
-
source = "input",
|
|
3257
|
-
limit = 12,
|
|
3258
|
-
category = "",
|
|
3259
|
-
} = {}) {
|
|
3260
|
-
const resolvedFile = path.resolve(filePath);
|
|
3261
|
-
const defaultLimit = Number.isInteger(limit) && limit > 0 ? limit : 12;
|
|
3262
|
-
const defaultCategory = category ? normalizeCategory(category) : "";
|
|
3263
|
-
const checkpoint = parseLearningEvalPayload(evalText, source);
|
|
3264
|
-
const profileExists = existsSync(resolvedFile);
|
|
3265
|
-
const profile = loadLearningProfile(resolvedFile);
|
|
3266
|
-
const audit = auditLearningProfile({ filePath: resolvedFile });
|
|
3267
|
-
const cases = checkpoint.cases.map((rawCase, index) => evaluateLearningCase(profile, rawCase, index, {
|
|
3268
|
-
defaultLimit,
|
|
3269
|
-
defaultCategory,
|
|
3270
|
-
}));
|
|
3271
|
-
const failed = cases.filter((item) => item.status === "fail").length;
|
|
3272
|
-
const warned = cases.filter((item) => item.status === "warn").length;
|
|
3273
|
-
const passed = cases.filter((item) => item.status === "pass").length;
|
|
3274
|
-
const recommendations = [];
|
|
3275
|
-
|
|
3276
|
-
if (!profileExists) {
|
|
3277
|
-
recommendations.push({
|
|
3278
|
-
level: "warning",
|
|
3279
|
-
text: "Learning profile does not exist; initialize or import entries before relying on eval results.",
|
|
3280
|
-
});
|
|
3281
|
-
}
|
|
3282
|
-
if (audit.summary.status !== "pass") {
|
|
3283
|
-
recommendations.push({
|
|
3284
|
-
level: audit.summary.failures > 0 ? "warning" : "info",
|
|
3285
|
-
text: "Run `design-ai learn --audit` before using eval checkpoints as a release gate.",
|
|
3286
|
-
});
|
|
3287
|
-
}
|
|
3288
|
-
if (failed > 0) {
|
|
3289
|
-
recommendations.push({
|
|
3290
|
-
level: "warning",
|
|
3291
|
-
text: "Review failed eval cases before trusting prompt/pack --with-learning selection.",
|
|
3292
|
-
});
|
|
3293
|
-
}
|
|
3294
|
-
|
|
3295
|
-
return {
|
|
3296
|
-
file: resolvedFile,
|
|
3297
|
-
source,
|
|
3298
|
-
profileExists,
|
|
3299
|
-
profileEntryCount: profile.entries.length,
|
|
3300
|
-
checkpointVersion: checkpoint.version,
|
|
3301
|
-
generatedAt: checkpoint.generatedAt,
|
|
3302
|
-
sourceProfile: checkpoint.sourceProfile,
|
|
3303
|
-
defaultLimit,
|
|
3304
|
-
defaultCategory,
|
|
3305
|
-
status: failed > 0 ? "fail" : warned > 0 ? "warn" : "pass",
|
|
3306
|
-
caseCount: cases.length,
|
|
3307
|
-
passed,
|
|
3308
|
-
warned,
|
|
3309
|
-
failed,
|
|
3310
|
-
auditSummary: audit.summary,
|
|
3311
|
-
cases,
|
|
3312
|
-
recommendations,
|
|
3313
|
-
privacy: {
|
|
3314
|
-
storesRawBriefText: false,
|
|
3315
|
-
storesBriefHash: true,
|
|
3316
|
-
exposesMatchedTokens: false,
|
|
3317
|
-
},
|
|
3318
|
-
};
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3321
|
-
function learningEvalTemplateCaseId(seed, index) {
|
|
3322
|
-
return `eval-${index + 1}-${shortHash(seed).slice(0, 10)}`;
|
|
3323
|
-
}
|
|
3324
|
-
|
|
3325
|
-
function learningEvalTemplateCaseFromEntry(entry, index) {
|
|
3326
|
-
return {
|
|
3327
|
-
id: learningEvalTemplateCaseId(`${entry.id}\n${entry.category}\n${entry.text}`, index),
|
|
3328
|
-
brief: entry.text,
|
|
3329
|
-
category: entry.category,
|
|
3330
|
-
limit: 1,
|
|
3331
|
-
expectedSelectedIds: [entry.id],
|
|
3332
|
-
minMatchedCount: 1,
|
|
3333
|
-
requireNoFallback: true,
|
|
3334
|
-
};
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
export function buildLearningEvalTemplate({
|
|
3338
|
-
filePath = defaultLearningFile(),
|
|
3339
|
-
query = "",
|
|
3340
|
-
category = "",
|
|
3341
|
-
limit = 6,
|
|
3342
|
-
now = new Date(),
|
|
3343
|
-
} = {}) {
|
|
3344
|
-
const resolvedFile = path.resolve(filePath);
|
|
3345
|
-
const normalizedCategory = category ? normalizeCategory(category) : "";
|
|
3346
|
-
const maxCases = Number.isInteger(limit) && limit > 0 ? limit : 6;
|
|
3347
|
-
const profileExists = existsSync(resolvedFile);
|
|
3348
|
-
const profile = loadLearningProfile(resolvedFile);
|
|
3349
|
-
const audit = auditLearningProfile({ filePath: resolvedFile });
|
|
3350
|
-
const cleanedQuery = cleanNoteText(query);
|
|
3351
|
-
const recommendations = [];
|
|
3352
|
-
let cases = [];
|
|
3353
|
-
let selectionSummary = null;
|
|
3354
|
-
|
|
3355
|
-
if (cleanedQuery) {
|
|
3356
|
-
const { selection } = selectLearningEntrySet(profile, {
|
|
3357
|
-
category: normalizedCategory,
|
|
3358
|
-
limit: maxCases,
|
|
3359
|
-
query: cleanedQuery,
|
|
3360
|
-
includeFallback: false,
|
|
3361
|
-
});
|
|
3362
|
-
const expectedSelectedIds = selection.selected.map((item) => item.id).filter(Boolean);
|
|
3363
|
-
selectionSummary = {
|
|
3364
|
-
mode: selection.mode,
|
|
3365
|
-
candidateCount: selection.candidateCount,
|
|
3366
|
-
matchedCount: selection.matchedCount,
|
|
3367
|
-
selectedCount: expectedSelectedIds.length,
|
|
3368
|
-
queryTokenCount: selection.queryTokenCount,
|
|
3369
|
-
fallbackCount: selection.fallbackCount,
|
|
3370
|
-
};
|
|
3371
|
-
if (expectedSelectedIds.length > 0) {
|
|
3372
|
-
const evalLimit = expectedSelectedIds.length;
|
|
3373
|
-
cases = [
|
|
3374
|
-
{
|
|
3375
|
-
id: learningEvalTemplateCaseId(`${cleanedQuery}\n${normalizedCategory}`, 0),
|
|
3376
|
-
brief: cleanedQuery,
|
|
3377
|
-
...(normalizedCategory ? { category: normalizedCategory } : {}),
|
|
3378
|
-
limit: evalLimit,
|
|
3379
|
-
expectedSelectedIds,
|
|
3380
|
-
minMatchedCount: expectedSelectedIds.length,
|
|
3381
|
-
requireNoFallback: true,
|
|
3382
|
-
},
|
|
3383
|
-
];
|
|
3384
|
-
}
|
|
3385
|
-
} else {
|
|
3386
|
-
const { entries, selection } = selectLearningEntrySet(profile, {
|
|
3387
|
-
category: normalizedCategory,
|
|
3388
|
-
limit: maxCases,
|
|
3389
|
-
includeFallback: false,
|
|
3390
|
-
});
|
|
3391
|
-
selectionSummary = {
|
|
3392
|
-
mode: selection.mode,
|
|
3393
|
-
candidateCount: selection.candidateCount,
|
|
3394
|
-
matchedCount: selection.matchedCount,
|
|
3395
|
-
selectedCount: entries.length,
|
|
3396
|
-
queryTokenCount: selection.queryTokenCount,
|
|
3397
|
-
fallbackCount: selection.fallbackCount,
|
|
3398
|
-
};
|
|
3399
|
-
cases = entries.map((entry, index) => learningEvalTemplateCaseFromEntry(entry, index));
|
|
3400
|
-
}
|
|
3401
|
-
|
|
3402
|
-
if (!profileExists) {
|
|
3403
|
-
recommendations.push({
|
|
3404
|
-
level: "warning",
|
|
3405
|
-
text: "Learning profile does not exist; create entries before generating durable eval checkpoints.",
|
|
3406
|
-
});
|
|
3407
|
-
}
|
|
3408
|
-
if (audit.summary.status !== "pass") {
|
|
3409
|
-
recommendations.push({
|
|
3410
|
-
level: audit.summary.failures > 0 ? "warning" : "info",
|
|
3411
|
-
text: "Run `design-ai learn --audit` before using generated eval checkpoints as a gate.",
|
|
3412
|
-
});
|
|
3413
|
-
}
|
|
3414
|
-
if (cases.length === 0) {
|
|
3415
|
-
recommendations.push({
|
|
3416
|
-
level: "info",
|
|
3417
|
-
text: cleanedQuery
|
|
3418
|
-
? "No matching learning entries found for the query; add or adjust learning entries before saving this checkpoint."
|
|
3419
|
-
: "No learning entries are available for checkpoint generation.",
|
|
3420
|
-
});
|
|
3421
|
-
}
|
|
3422
|
-
|
|
3423
|
-
return {
|
|
3424
|
-
version: 1,
|
|
3425
|
-
generatedAt: now.toISOString(),
|
|
3426
|
-
sourceProfile: {
|
|
3427
|
-
file: resolvedFile,
|
|
3428
|
-
exists: profileExists,
|
|
3429
|
-
entryCount: profile.entries.length,
|
|
3430
|
-
auditStatus: audit.summary.status,
|
|
3431
|
-
category: normalizedCategory,
|
|
3432
|
-
query: cleanedQuery,
|
|
3433
|
-
limit: maxCases,
|
|
3434
|
-
},
|
|
3435
|
-
selection: selectionSummary,
|
|
3436
|
-
caseCount: cases.length,
|
|
3437
|
-
cases,
|
|
3438
|
-
recommendations,
|
|
3439
|
-
privacy: {
|
|
3440
|
-
storesRawBriefText: true,
|
|
3441
|
-
storesBriefHash: false,
|
|
3442
|
-
exposesMatchedTokens: false,
|
|
3443
|
-
},
|
|
3444
|
-
};
|
|
3445
|
-
}
|
|
3446
|
-
|
|
3447
|
-
export function learningStats({ filePath = defaultLearningFile() } = {}) {
|
|
3448
|
-
const audit = auditLearningProfile({ filePath });
|
|
3449
|
-
const payload = {
|
|
3450
|
-
file: filePath,
|
|
3451
|
-
exists: audit.exists,
|
|
3452
|
-
version: audit.version,
|
|
3453
|
-
updatedAt: audit.updatedAt,
|
|
3454
|
-
count: audit.count,
|
|
3455
|
-
categoryCounts: audit.categoryCounts,
|
|
3456
|
-
sourceCounts: {},
|
|
3457
|
-
oldestEntry: null,
|
|
3458
|
-
latestEntry: null,
|
|
3459
|
-
auditSummary: audit.summary,
|
|
3460
|
-
};
|
|
3461
|
-
|
|
3462
|
-
if (!audit.exists || audit.summary.failures > 0) return payload;
|
|
3463
|
-
|
|
3464
|
-
const profile = loadLearningProfile(filePath);
|
|
3465
|
-
const entries = profile.entries.filter((entry) => entry.text);
|
|
3466
|
-
payload.sourceCounts = countBy(entries, (entry) => entry.source || "cli");
|
|
3467
|
-
|
|
3468
|
-
const datedEntries = entries
|
|
3469
|
-
.map((entry) => ({ entry, time: Date.parse(entry.createdAt) }))
|
|
3470
|
-
.filter(({ time }) => !Number.isNaN(time))
|
|
3471
|
-
.sort((a, b) => a.time - b.time);
|
|
3472
|
-
|
|
3473
|
-
if (datedEntries.length > 0) {
|
|
3474
|
-
payload.oldestEntry = statsEntry(datedEntries[0].entry);
|
|
3475
|
-
payload.latestEntry = statsEntry(datedEntries[datedEntries.length - 1].entry);
|
|
3476
|
-
}
|
|
3477
|
-
|
|
3478
|
-
return payload;
|
|
3479
|
-
}
|
|
3480
|
-
|
|
3481
|
-
export function learningUsageStats({
|
|
3482
|
-
filePath = defaultLearningFile(),
|
|
3483
|
-
usageFile = defaultLearningUsageFile(filePath),
|
|
3484
|
-
limit = 10,
|
|
3485
|
-
} = {}) {
|
|
3486
|
-
const resolvedFile = path.resolve(filePath);
|
|
3487
|
-
const resolvedUsageFile = path.resolve(usageFile);
|
|
3488
|
-
const profileExists = existsSync(resolvedFile);
|
|
3489
|
-
const usageExists = existsSync(resolvedUsageFile);
|
|
3490
|
-
const profile = loadLearningProfile(resolvedFile);
|
|
3491
|
-
const usageLog = loadLearningUsageLog(resolvedUsageFile, { profileFile: resolvedFile });
|
|
3492
|
-
const events = usageLog.events;
|
|
3493
|
-
const maxRecentEvents = Number.isInteger(limit) && limit > 0 ? limit : 10;
|
|
3494
|
-
const sortedEvents = [...events].sort((a, b) => usageEventTime(a) - usageEventTime(b));
|
|
3495
|
-
const recentEvents = [...sortedEvents].reverse().slice(0, maxRecentEvents).map(usageEventSummary);
|
|
3496
|
-
const entryUsage = new Map();
|
|
3497
|
-
|
|
3498
|
-
for (const event of events) {
|
|
3499
|
-
for (const entryId of event.selectedEntryIds) {
|
|
3500
|
-
incrementUsageEntry(entryUsage, entryId, event);
|
|
3501
|
-
}
|
|
3502
|
-
}
|
|
3503
|
-
|
|
3504
|
-
const profileEntryIds = new Set(profile.entries.map((entry) => entry.id));
|
|
3505
|
-
const selectedEntryIds = [...entryUsage.keys()].sort();
|
|
3506
|
-
const usedEntryIds = selectedEntryIds.filter((entryId) => profileEntryIds.has(entryId));
|
|
3507
|
-
const staleSelectedEntryIds = selectedEntryIds.filter((entryId) => !profileEntryIds.has(entryId));
|
|
3508
|
-
const unusedEntryIds = profile.entries
|
|
3509
|
-
.filter((entry) => !entryUsage.has(entry.id))
|
|
3510
|
-
.map((entry) => entry.id);
|
|
3511
|
-
const topSelectedEntries = profile.entries
|
|
3512
|
-
.map((entry) => usageEntrySummary(entry, entryUsage.get(entry.id)))
|
|
3513
|
-
.filter((entry) => entry.usageCount > 0)
|
|
3514
|
-
.sort((a, b) => b.usageCount - a.usageCount || String(b.latestUsedAt).localeCompare(String(a.latestUsedAt)))
|
|
3515
|
-
.slice(0, maxRecentEvents);
|
|
3516
|
-
const recommendations = [];
|
|
3517
|
-
|
|
3518
|
-
if (!usageExists) {
|
|
3519
|
-
recommendations.push({
|
|
3520
|
-
level: "info",
|
|
3521
|
-
text: "No learning usage sidecar exists yet. Run prompt or pack with --with-learning to record local usage metadata.",
|
|
3522
|
-
});
|
|
3523
|
-
} else if (events.length === 0) {
|
|
3524
|
-
recommendations.push({
|
|
3525
|
-
level: "info",
|
|
3526
|
-
text: "Learning usage sidecar exists but has no events yet.",
|
|
3527
|
-
});
|
|
3528
|
-
}
|
|
3529
|
-
if (profile.entries.length > 0 && unusedEntryIds.length > 0) {
|
|
3530
|
-
recommendations.push({
|
|
3531
|
-
level: "info",
|
|
3532
|
-
text: "Review unused learning entries before curating; unused does not mean obsolete until enough prompt/pack usage has accumulated.",
|
|
3533
|
-
});
|
|
3534
|
-
}
|
|
3535
|
-
if (staleSelectedEntryIds.length > 0) {
|
|
3536
|
-
recommendations.push({
|
|
3537
|
-
level: "warning",
|
|
3538
|
-
text: "Usage sidecar references entry ids that are no longer present in the active learning profile.",
|
|
3539
|
-
});
|
|
3540
|
-
}
|
|
3541
|
-
|
|
3542
|
-
return {
|
|
3543
|
-
file: resolvedFile,
|
|
3544
|
-
usageFile: resolvedUsageFile,
|
|
3545
|
-
exists: usageExists,
|
|
3546
|
-
profileExists,
|
|
3547
|
-
profileFile: usageLog.profileFile || resolvedFile,
|
|
3548
|
-
version: usageLog.version,
|
|
3549
|
-
updatedAt: usageLog.updatedAt,
|
|
3550
|
-
eventCount: events.length,
|
|
3551
|
-
profileEntryCount: profile.entries.length,
|
|
3552
|
-
usedEntryCount: usedEntryIds.length,
|
|
3553
|
-
unusedEntryCount: unusedEntryIds.length,
|
|
3554
|
-
staleSelectedEntryCount: staleSelectedEntryIds.length,
|
|
3555
|
-
commandCounts: countBy(events, (event) => event.command),
|
|
3556
|
-
routeCounts: countBy(events, (event) => event.routeId || "unrouted"),
|
|
3557
|
-
categoryCounts: countBy(events, (event) => event.category || "all"),
|
|
3558
|
-
auditStatusCounts: countBy(events, (event) => event.auditStatus || "unknown"),
|
|
3559
|
-
selectedEntryCounts: Object.fromEntries(
|
|
3560
|
-
selectedEntryIds.map((entryId) => [entryId, entryUsage.get(entryId).count]),
|
|
3561
|
-
),
|
|
3562
|
-
topSelectedEntries,
|
|
3563
|
-
unusedEntryIds,
|
|
3564
|
-
staleSelectedEntryIds,
|
|
3565
|
-
oldestEvent: sortedEvents.length > 0 ? usageEventSummary(sortedEvents[0]) : null,
|
|
3566
|
-
latestEvent: sortedEvents.length > 0 ? usageEventSummary(sortedEvents[sortedEvents.length - 1]) : null,
|
|
3567
|
-
recentEvents,
|
|
3568
|
-
recommendations,
|
|
3569
|
-
privacy: {
|
|
3570
|
-
storesRawBriefText: false,
|
|
3571
|
-
storesBriefHash: true,
|
|
3572
|
-
storesSelectedEntryIds: true,
|
|
3573
|
-
},
|
|
3574
|
-
};
|
|
3575
|
-
}
|
|
3576
|
-
|
|
3577
|
-
export function formatLearningJson(payload) {
|
|
3578
|
-
return JSON.stringify(payload, null, 2);
|
|
3579
|
-
}
|
|
3580
|
-
|
|
3581
|
-
function formatLearningSummary(summary) {
|
|
3582
|
-
if (!summary) return "unknown";
|
|
3583
|
-
return `${summary.status} (${summary.failures} failure(s), ${summary.warnings} warning(s))`;
|
|
3584
|
-
}
|
|
3585
|
-
|
|
3586
|
-
function yesNo(value) {
|
|
3587
|
-
return value ? "yes" : "no";
|
|
3588
|
-
}
|
|
3589
|
-
|
|
3590
|
-
function learningReportListItem(label, value) {
|
|
3591
|
-
return `- ${label}: ${value}`;
|
|
3592
|
-
}
|
|
3593
|
-
|
|
3594
|
-
export function renderLearningCurationReport(payload, {
|
|
3595
|
-
generatedAt = new Date(),
|
|
3596
|
-
} = {}) {
|
|
3597
|
-
const generatedAtText = generatedAt instanceof Date ? generatedAt.toISOString() : String(generatedAt || "");
|
|
3598
|
-
const mode = payload.applied ? "applied" : "preview";
|
|
3599
|
-
const lines = [
|
|
3600
|
-
"# Learning Curation Report",
|
|
3601
|
-
"",
|
|
3602
|
-
learningReportListItem("Generated", generatedAtText),
|
|
3603
|
-
learningReportListItem("Mode", mode),
|
|
3604
|
-
learningReportListItem("File", payload.file),
|
|
3605
|
-
learningReportListItem("Archive", payload.archiveFile),
|
|
3606
|
-
learningReportListItem("Before", formatLearningSummary(payload.before)),
|
|
3607
|
-
learningReportListItem("After", payload.after ? formatLearningSummary(payload.after) : "not applied"),
|
|
3608
|
-
learningReportListItem("Proposals", payload.proposalCount),
|
|
3609
|
-
learningReportListItem("Archive candidates", payload.archiveCount),
|
|
3610
|
-
learningReportListItem("Manual review", payload.manualReviewCount),
|
|
3611
|
-
"",
|
|
3612
|
-
"## Archive Candidates",
|
|
3613
|
-
"",
|
|
3614
|
-
];
|
|
3615
|
-
|
|
3616
|
-
const archiveCandidates = (payload.proposals || []).filter((proposal) => proposal.action === "archive");
|
|
3617
|
-
if (archiveCandidates.length === 0) {
|
|
3618
|
-
lines.push("No archive candidates found.");
|
|
3619
|
-
} else {
|
|
3620
|
-
for (const proposal of archiveCandidates) {
|
|
3621
|
-
lines.push(`- \`${proposal.entryId}\`: ${proposal.reason}`);
|
|
3622
|
-
lines.push(` - Issues: ${(proposal.issueCodes || []).join(", ") || "none"}`);
|
|
3623
|
-
if (proposal.category) lines.push(` - Category: ${proposal.category}`);
|
|
3624
|
-
if (proposal.textPreview) lines.push(` - Preview: ${proposal.textPreview}`);
|
|
3625
|
-
}
|
|
3626
|
-
}
|
|
3627
|
-
|
|
3628
|
-
lines.push("", "## Manual Review", "");
|
|
3629
|
-
const manualCandidates = (payload.proposals || []).filter((proposal) => proposal.action === "manual-review");
|
|
3630
|
-
if (manualCandidates.length === 0) {
|
|
3631
|
-
lines.push("No profile curation items need manual review.");
|
|
3632
|
-
} else {
|
|
3633
|
-
for (const proposal of manualCandidates) {
|
|
3634
|
-
const label = proposal.entryId || "profile";
|
|
3635
|
-
lines.push(`- \`${label}\`: ${proposal.reason}`);
|
|
3636
|
-
lines.push(` - Issues: ${(proposal.issueCodes || []).join(", ") || "none"}`);
|
|
3637
|
-
if (proposal.textPreview) lines.push(` - Preview: ${proposal.textPreview}`);
|
|
3638
|
-
}
|
|
3639
|
-
}
|
|
3640
|
-
|
|
3641
|
-
lines.push("", "## Usage Review", "");
|
|
3642
|
-
const usage = payload.usage || {};
|
|
3643
|
-
lines.push(learningReportListItem("Sidecar", usage.usageFile || "not available"));
|
|
3644
|
-
lines.push(learningReportListItem("Exists", yesNo(Boolean(usage.exists))));
|
|
3645
|
-
lines.push(learningReportListItem("Profile file", usage.profileFile || payload.file));
|
|
3646
|
-
lines.push(learningReportListItem("Profile file matches", yesNo(usage.profileFileMatches !== false)));
|
|
3647
|
-
lines.push(learningReportListItem("Events", usage.eventCount || 0));
|
|
3648
|
-
lines.push(learningReportListItem("Review items", usage.reviewCount || 0));
|
|
3649
|
-
lines.push(learningReportListItem("Auto archive from usage", yesNo(Boolean(usage.autoArchive))));
|
|
3650
|
-
if (usage.error) lines.push(learningReportListItem("Error", usage.error));
|
|
3651
|
-
|
|
3652
|
-
if (Array.isArray(usage.reviews) && usage.reviews.length > 0) {
|
|
3653
|
-
lines.push("");
|
|
3654
|
-
for (const review of usage.reviews) {
|
|
3655
|
-
const label = review.entryId || "usage";
|
|
3656
|
-
lines.push(`- \`${label}\`: ${review.reason} (${review.action})`);
|
|
3657
|
-
if (review.level) lines.push(` - Level: ${review.level}`);
|
|
3658
|
-
if (Number.isInteger(review.usageCount)) lines.push(` - Usage count: ${review.usageCount}`);
|
|
3659
|
-
if (review.textPreview) lines.push(` - Preview: ${review.textPreview}`);
|
|
3660
|
-
}
|
|
3661
|
-
} else {
|
|
3662
|
-
lines.push("");
|
|
3663
|
-
lines.push("No usage-based curation review items found.");
|
|
3664
|
-
}
|
|
3665
|
-
|
|
3666
|
-
lines.push("", "## Skipped", "");
|
|
3667
|
-
if (!Array.isArray(payload.skipped) || payload.skipped.length === 0) {
|
|
3668
|
-
lines.push("No curation steps were skipped.");
|
|
3669
|
-
} else {
|
|
3670
|
-
for (const skipped of payload.skipped) {
|
|
3671
|
-
const label = skipped.entryId ? `\`${skipped.entryId}\`` : "profile";
|
|
3672
|
-
lines.push(`- ${label}: ${skipped.reason}`);
|
|
3673
|
-
if (skipped.message) lines.push(` - ${skipped.message}`);
|
|
3674
|
-
}
|
|
3675
|
-
}
|
|
3676
|
-
|
|
3677
|
-
lines.push("", "## Privacy", "");
|
|
3678
|
-
lines.push("- Report text may include learning entry previews, but usage review does not include raw prompt or pack brief text.");
|
|
3679
|
-
lines.push("- Usage sidecars store selected entry ids and short brief hashes; usage review remains advisory and never archives entries by itself.");
|
|
3680
|
-
|
|
3681
|
-
lines.push("", "## Next Steps", "");
|
|
3682
|
-
if (payload.applied) {
|
|
3683
|
-
lines.push("- Review the archive file before sharing or deleting local learning history.");
|
|
3684
|
-
lines.push("- Run `design-ai learn --audit` and `design-ai workspace --strict` after curation to confirm readiness.");
|
|
3685
|
-
} else if ((payload.archiveCount || 0) > 0) {
|
|
3686
|
-
lines.push("- Review archive candidates, then rerun `design-ai learn --curate --yes` only if the proposed archive actions are correct.");
|
|
3687
|
-
lines.push("- Keep usage-only review items as manual signals until enough prompt/pack usage has accumulated.");
|
|
3688
|
-
} else {
|
|
3689
|
-
lines.push("- No archive action is required from this curation report.");
|
|
3690
|
-
lines.push("- Continue recording prompt/pack usage with `--with-learning` before making usage-based decisions.");
|
|
3691
|
-
}
|
|
3692
|
-
|
|
3693
|
-
return `${lines.join("\n")}\n`;
|
|
3694
|
-
}
|
|
1
|
+
// Public learning surface for `design-ai learn` — re-exports the learn-* modules.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
LEARNING_CATEGORIES,
|
|
5
|
+
LEARNING_FEEDBACK_OUTCOMES,
|
|
6
|
+
LEARN_SUBCOMMANDS,
|
|
7
|
+
normalizeCategory,
|
|
8
|
+
normalizeFeedbackOutcome,
|
|
9
|
+
parseLearnArgs,
|
|
10
|
+
parseLearningKeep,
|
|
11
|
+
parseLearningLimit,
|
|
12
|
+
parseLearningMinEvidence,
|
|
13
|
+
} from "./learn-args.mjs";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
buildLearningBackup,
|
|
17
|
+
buildRedactedLearningBackup,
|
|
18
|
+
diffLearningProfiles,
|
|
19
|
+
importLearningProfile,
|
|
20
|
+
listLearningRestoreBackups,
|
|
21
|
+
pruneLearningRestoreBackups,
|
|
22
|
+
restoreLearningProfile,
|
|
23
|
+
verifyLearningImportPayload,
|
|
24
|
+
} from "./learn-backup.mjs";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
applyLearningCurationPlan,
|
|
28
|
+
buildLearningCurationPlan,
|
|
29
|
+
clearLearning,
|
|
30
|
+
defaultLearningArchiveFile,
|
|
31
|
+
emptyLearningArchive,
|
|
32
|
+
loadLearningArchive,
|
|
33
|
+
renderLearningCurationReport,
|
|
34
|
+
} from "./learn-curation.mjs";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
buildLearningEvalTemplate,
|
|
38
|
+
learningEvalReport,
|
|
39
|
+
} from "./learn-eval.mjs";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
LEARNING_INIT_ENTRIES,
|
|
43
|
+
LEARNING_INIT_SOURCE,
|
|
44
|
+
applyLearningAuditFixes,
|
|
45
|
+
auditLearningProfile,
|
|
46
|
+
captureLearningEntries,
|
|
47
|
+
emptyLearningProfile,
|
|
48
|
+
forgetLearning,
|
|
49
|
+
initializeLearningProfile,
|
|
50
|
+
loadLearningProfile,
|
|
51
|
+
normalizeLearningProfile,
|
|
52
|
+
recordLearningFeedback,
|
|
53
|
+
rememberLearning,
|
|
54
|
+
} from "./learn-profile.mjs";
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
buildLearningContext,
|
|
58
|
+
recentLearningEntries,
|
|
59
|
+
renderLearningMarkdown,
|
|
60
|
+
selectLearningEntries,
|
|
61
|
+
selectLearningEntrySet,
|
|
62
|
+
} from "./learn-select.mjs";
|
|
63
|
+
|
|
64
|
+
export {
|
|
65
|
+
buildLearnRecall,
|
|
66
|
+
} from "./recall.mjs";
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
defaultLearningFile,
|
|
70
|
+
defaultLearningRestoreBackupFile,
|
|
71
|
+
defaultLearningUsageFile,
|
|
72
|
+
formatLearningJson,
|
|
73
|
+
} from "./learn-shared.mjs";
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
buildLearningUsageEvent,
|
|
77
|
+
emptyLearningUsageLog,
|
|
78
|
+
learningStats,
|
|
79
|
+
learningUsageStats,
|
|
80
|
+
loadLearningUsageLog,
|
|
81
|
+
normalizeLearningUsageLog,
|
|
82
|
+
recordLearningUsage,
|
|
83
|
+
} from "./learn-usage.mjs";
|