@jpkme/diff-review 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -34
  3. package/lib/acp-policy.mjs +136 -0
  4. package/lib/acp-policy.test.mjs +83 -0
  5. package/lib/acp-shell.mjs +379 -0
  6. package/lib/acp-shell.test.mjs +127 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +87 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +29 -11
  13. package/lib/extract-cache.test.mjs +162 -0
  14. package/lib/extract.mjs +197 -1
  15. package/lib/model-label.mjs +263 -0
  16. package/lib/model-label.test.mjs +77 -0
  17. package/lib/models-cache.mjs +75 -0
  18. package/lib/models-cache.test.mjs +50 -0
  19. package/lib/output-name.mjs +8 -0
  20. package/lib/prompt.mjs +168 -22
  21. package/lib/review-dir.mjs +108 -0
  22. package/lib/review-meta.mjs +5 -3
  23. package/lib/review-meta.test.mjs +11 -0
  24. package/lib/review-prep.mjs +63 -0
  25. package/lib/validate.mjs +2 -2
  26. package/lib/wait-output.mjs +46 -23
  27. package/lib/wait-output.test.mjs +24 -1
  28. package/package.json +3 -1
  29. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
  30. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  31. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  32. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
  33. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  34. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  35. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
  36. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  37. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  38. package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → B5ByWcPE.js} +2 -2
  39. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  40. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  41. package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
  42. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  43. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  44. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  45. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  46. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  47. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
  48. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  49. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  50. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  51. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  52. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  53. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
  54. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  55. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  56. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
  57. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  58. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  59. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
  60. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
  61. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
  62. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
  63. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
  64. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  65. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
  66. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  67. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  68. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
  69. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  70. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  71. package/web/build/client/_app/version.json +1 -1
  72. package/web/build/client/_app/version.json.br +0 -0
  73. package/web/build/client/_app/version.json.gz +0 -0
  74. package/web/build/handler.js +3 -3
  75. package/web/build/index.js +3 -3
  76. package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
  77. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
  78. package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
  79. package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
  80. package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
  81. package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
  82. package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
  83. package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
  84. package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
  85. package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
  86. package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
  87. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
  88. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CpO_G8z8.js} +70 -704
  89. package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
  90. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-Di1wJQuU.js} +3 -3
  91. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-Di1wJQuU.js.map} +1 -1
  92. package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
  93. package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
  94. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-yLYmrAWy.js} +3 -5
  95. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
  96. package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
  97. package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
  98. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
  99. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
  100. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
  101. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
  102. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  103. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  104. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  105. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  106. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  107. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  108. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  110. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  111. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  112. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  113. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  114. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  115. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  116. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  117. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  119. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  120. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  121. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  122. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  123. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  124. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
  125. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  126. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  127. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  128. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  129. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  130. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  131. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  132. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  133. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
  134. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  135. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  136. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  137. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  138. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  139. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  140. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  141. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  142. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  143. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  144. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  145. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  146. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  147. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  148. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  149. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  150. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  151. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  152. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  153. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
@@ -0,0 +1,263 @@
1
+ /** Compara nombres de modelo ignorando mayúsculas, espacios y puntuación. */
2
+ export function modelSlug(s) {
3
+ return String(s || "")
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "");
7
+ }
8
+
9
+ /** Parece un id técnico (claude-opus-5-5) más que un nombre legible. */
10
+ function isSlugLike(s) {
11
+ const t = String(s || "").trim();
12
+ if (!t) return true;
13
+ if (/\s/.test(t)) return false;
14
+ if (/[A-Z]/.test(t) && /[a-z]/.test(t) && !t.includes("-")) return false; // "Sonnet", "Auto"
15
+ return /^[a-z0-9][a-z0-9._|-]*$/i.test(t) && (t.includes("-") || t.includes(".") || t.includes("["));
16
+ }
17
+
18
+ /**
19
+ * claude-opus-5-5 → Claude Opus 5.5; gpt-5.6-sol → GPT-5.6 Sol; gemini-3.8-flash → Gemini 3.8 Flash
20
+ * @param {string} id
21
+ */
22
+ export function humanizeModelId(id) {
23
+ const base = String(id || "")
24
+ .trim()
25
+ .replace(/\[.*\]$/, "");
26
+ if (!base) return "";
27
+ const parts = base.split(/[-_]/).filter(Boolean);
28
+ const out = [];
29
+ for (let i = 0; i < parts.length; i++) {
30
+ const p = parts[i];
31
+ const lower = p.toLowerCase();
32
+ if (lower === "gpt" || lower === "gpt5") {
33
+ out.push(lower === "gpt5" ? "GPT-5" : "GPT");
34
+ continue;
35
+ }
36
+ if (lower === "claude" || lower === "gemini" || lower === "grok" || lower === "composer" || lower === "muse") {
37
+ out.push(lower.charAt(0).toUpperCase() + lower.slice(1));
38
+ continue;
39
+ }
40
+ if (lower === "sonnet" || lower === "opus" || lower === "haiku" || lower === "flash" || lower === "codex") {
41
+ out.push(lower.charAt(0).toUpperCase() + lower.slice(1));
42
+ continue;
43
+ }
44
+ // 5-5 → 5.5 (versión mayor-menor)
45
+ if (/^\d+$/.test(p) && i + 1 < parts.length && /^\d+$/.test(parts[i + 1])) {
46
+ out.push(`${p}.${parts[i + 1]}`);
47
+ i++;
48
+ continue;
49
+ }
50
+ if (/^\d+(\.\d+)?$/.test(p)) {
51
+ out.push(p);
52
+ continue;
53
+ }
54
+ out.push(p.charAt(0).toUpperCase() + p.slice(1).toLowerCase());
55
+ }
56
+ return out.join(" ");
57
+ }
58
+
59
+ /**
60
+ * context=300k,effort=medium,fast=false → ["300k", "medium"]
61
+ * @param {string} raw
62
+ * @returns {string[]}
63
+ */
64
+ export function compactModelMeta(raw) {
65
+ const s = String(raw || "").trim();
66
+ if (!s) return [];
67
+ /** @type {string[]} */
68
+ const badges = [];
69
+ for (const piece of s.split(",")) {
70
+ const part = piece.trim();
71
+ if (!part) continue;
72
+ const eq = part.indexOf("=");
73
+ if (eq < 0) {
74
+ // bare token: "1m", "1M"
75
+ badges.push(part);
76
+ continue;
77
+ }
78
+ const key = part.slice(0, eq).trim().toLowerCase();
79
+ const val = part.slice(eq + 1).trim();
80
+ if (!val || val === "false" || val === "0") continue;
81
+ if (key === "fast" || key === "thinking") {
82
+ if (val === "true" || val === "1") badges.push(key);
83
+ continue;
84
+ }
85
+ if (key === "context" || key === "ctx") {
86
+ badges.push(val);
87
+ continue;
88
+ }
89
+ if (key === "effort" || key === "reasoning_effort" || key === "reasoning") {
90
+ badges.push(val);
91
+ continue;
92
+ }
93
+ badges.push(val);
94
+ }
95
+ return badges;
96
+ }
97
+
98
+ /**
99
+ * @param {string} name
100
+ * @param {string} value
101
+ * @returns {{ title: string, badges: string[], label: string, hint?: string }}
102
+ */
103
+ export function formatModelOption(name, value) {
104
+ const n = String(name ?? "").trim();
105
+ const v = String(value ?? "").trim();
106
+ if (!v && !n) return { title: "", badges: [], label: "" };
107
+ if (/^default(\[\])?$/i.test(v) && n) {
108
+ return { title: n, badges: [], label: n };
109
+ }
110
+
111
+ const bracket = /^(.+?)\[(.*)\]$/.exec(v);
112
+ const base = bracket ? bracket[1].trim() : v;
113
+ const metaRaw = bracket ? bracket[2].trim() : "";
114
+ let badges = compactModelMeta(metaRaw);
115
+
116
+ let title;
117
+ if (n && !isSlugLike(n)) {
118
+ title = n;
119
+ } else if (n && modelSlug(n) === modelSlug(base)) {
120
+ title = isSlugLike(n) ? humanizeModelId(base || n) : n;
121
+ } else if (n && isSlugLike(n)) {
122
+ title = humanizeModelId(base || n);
123
+ } else if (n) {
124
+ title = n;
125
+ } else {
126
+ title = humanizeModelId(base) || base || v;
127
+ }
128
+
129
+ // Si el título ya menciona el contexto (p. ej. "Sonnet (1M context)"), no repetir el badge "1m".
130
+ if (badges.length && /\(\s*\d+\s*m\b/i.test(title)) {
131
+ badges = badges.filter((b) => !/^\d+m$/i.test(b));
132
+ }
133
+ const titleSlug = modelSlug(title);
134
+ if (badges.length && titleSlug.includes(modelSlug(badges.join("")))) {
135
+ /* keep badges anyway for effort/fast */
136
+ }
137
+
138
+ const hint = badges.length ? `[ ${badges.join(", ")} ]` : undefined;
139
+ const label = hint ? `${title} · ${hint}` : title;
140
+ return { title, badges, label, ...(hint ? { hint } : {}) };
141
+ }
142
+
143
+ /**
144
+ * Etiqueta compacta (compat / un solo string).
145
+ * @param {string} name
146
+ * @param {string} value
147
+ */
148
+ export function modelOptionLabel(name, value) {
149
+ return formatModelOption(name, value).label || String(name || value || "").trim();
150
+ }
151
+
152
+ /**
153
+ * Etiqueta del ítem "usar el default del agente".
154
+ * @param {{ value: string, name: string } | undefined} current
155
+ * @param {string} [configuredOptionLabel]
156
+ */
157
+ export function defaultModelOptionLabel(current, configuredOptionLabel) {
158
+ const name = String(current?.name ?? "").trim();
159
+ const value = String(current?.value ?? "").trim();
160
+ const generic =
161
+ !name ||
162
+ value === "default" ||
163
+ value === "auto" ||
164
+ /^default(\b|\s|\()/i.test(name) ||
165
+ /^auto(\b|\s|\()/i.test(name) ||
166
+ /\(recommended\)/i.test(name);
167
+
168
+ if (generic) {
169
+ const configured = String(configuredOptionLabel ?? "").trim();
170
+ if (configured && configured !== "Por defecto del agente") return configured;
171
+ return "Por defecto del agente";
172
+ }
173
+
174
+ const { title, hint } = formatModelOption(name, value);
175
+ return hint ? `Por defecto · ${title} · ${hint}` : `Por defecto · ${title}`;
176
+ }
177
+
178
+ /**
179
+ * Orden de la lista: default (`""`) y `auto` primero, resto A→Z por label.
180
+ * @template {{ value: string, label?: string, title?: string }} T
181
+ * @param {T[]} options
182
+ * @returns {T[]}
183
+ */
184
+ export function sortModelSelectOptions(options) {
185
+ const list = Array.isArray(options) ? [...options] : [];
186
+
187
+ /** @param {{ value: string, label?: string, title?: string }} o */
188
+ const isAuto = (o) => {
189
+ const v = String(o.value ?? "").toLowerCase();
190
+ if (v === "auto" || v.startsWith("auto[")) return true;
191
+ const t = String(o.title || o.label || "").trim();
192
+ return /^auto\b/i.test(t);
193
+ };
194
+
195
+ const defaults = list.filter((o) => o.value === "");
196
+ const autos = list.filter((o) => o.value !== "" && isAuto(o));
197
+ const middle = list.filter((o) => o.value !== "" && !isAuto(o));
198
+
199
+ /** @param {{ label?: string, title?: string, value: string }} a */
200
+ /** @param {{ label?: string, title?: string, value: string }} b */
201
+ const byLabel = (a, b) =>
202
+ String(a.label || a.title || a.value).localeCompare(String(b.label || b.title || b.value), "es", {
203
+ sensitivity: "base",
204
+ numeric: true,
205
+ });
206
+
207
+ autos.sort(byLabel);
208
+ middle.sort(byLabel);
209
+ return [...defaults, ...autos, ...middle];
210
+ }
211
+
212
+ /**
213
+ * Misma lista que la UI: default + aliases fijos + modelos ACP.
214
+ * Clack: `label` = título amigable, `hint` = `[ 300k, medium ]`.
215
+ * UI: también `title` + `badges` para chips.
216
+ *
217
+ * @param {{ models: { value: string, name: string }[], currentModel: string }} live
218
+ * @param {{ options: { value: string, label: string, hint?: string }[], initialValue?: string }} fixed
219
+ * @returns {{ options: { value: string, label: string, hint?: string, title?: string, badges?: string[] }[], initialValue: string }}
220
+ */
221
+ export function buildAcpModelSelectOptions(live, fixed) {
222
+ const fixedOptions = fixed?.options ?? [];
223
+ const fallback = fixedOptions.filter((m) => m.value !== "");
224
+ const configuredLabel = fixedOptions.find((m) => m.value === "")?.label;
225
+ const current = live.models.find((m) => m.value === live.currentModel);
226
+ const seen = new Set(["", "default"]);
227
+ /** @type {{ value: string, label: string, hint?: string, title?: string, badges?: string[] }[]} */
228
+ const options = [
229
+ {
230
+ value: "",
231
+ label: defaultModelOptionLabel(current, configuredLabel),
232
+ title: defaultModelOptionLabel(current, configuredLabel),
233
+ badges: [],
234
+ },
235
+ ];
236
+
237
+ for (const m of fallback) {
238
+ if (seen.has(m.value)) continue;
239
+ seen.add(m.value);
240
+ options.push({
241
+ value: m.value,
242
+ label: m.label,
243
+ title: m.label,
244
+ badges: [],
245
+ ...(m.hint ? { hint: m.hint } : {}),
246
+ });
247
+ }
248
+
249
+ for (const m of live.models) {
250
+ if (seen.has(m.value)) continue;
251
+ seen.add(m.value);
252
+ const formatted = formatModelOption(m.name, m.value);
253
+ options.push({
254
+ value: m.value,
255
+ label: formatted.title,
256
+ title: formatted.title,
257
+ badges: formatted.badges,
258
+ ...(formatted.hint ? { hint: formatted.hint } : {}),
259
+ });
260
+ }
261
+
262
+ return { options: sortModelSelectOptions(options), initialValue: "" };
263
+ }
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ buildAcpModelSelectOptions,
5
+ compactModelMeta,
6
+ formatModelOption,
7
+ humanizeModelId,
8
+ modelOptionLabel,
9
+ } from "./model-label.mjs";
10
+
11
+ test("humanizeModelId", () => {
12
+ assert.equal(humanizeModelId("claude-opus-5-5"), "Claude Opus 5.5");
13
+ assert.equal(humanizeModelId("claude-sonnet-5.5"), "Claude Sonnet 5.5");
14
+ assert.equal(humanizeModelId("gemini-3.8-flash"), "Gemini 3.8 Flash");
15
+ assert.equal(humanizeModelId("gpt-5.6-sol"), "GPT 5.6 Sol");
16
+ });
17
+
18
+ test("compactModelMeta", () => {
19
+ assert.deepEqual(compactModelMeta("context=300k,effort=medium,fast=false"), ["300k", "medium"]);
20
+ assert.deepEqual(compactModelMeta("thinking=true,context=300k,effort=high,fast=false"), [
21
+ "thinking",
22
+ "300k",
23
+ "high",
24
+ ]);
25
+ assert.deepEqual(compactModelMeta("1m"), ["1m"]);
26
+ });
27
+
28
+ test("formatModelOption compacto y amigable", () => {
29
+ const a = formatModelOption("claude-opus-5-5", "claude-opus-5-5[context=300k,effort=medium,fast=false]");
30
+ assert.equal(a.title, "Claude Opus 5.5");
31
+ assert.deepEqual(a.badges, ["300k", "medium"]);
32
+ assert.equal(a.label, "Claude Opus 5.5 · [ 300k, medium ]");
33
+ assert.equal(a.hint, "[ 300k, medium ]");
34
+
35
+ const b = formatModelOption("Claude Sonnet 5.5", "claude-sonnet-5.5");
36
+ assert.equal(b.title, "Claude Sonnet 5.5");
37
+ assert.deepEqual(b.badges, []);
38
+ assert.equal(b.label, "Claude Sonnet 5.5");
39
+
40
+ const c = formatModelOption("Sonnet (1M context)", "sonnet[1m]");
41
+ assert.equal(c.title, "Sonnet (1M context)");
42
+ assert.deepEqual(c.badges, []);
43
+ });
44
+
45
+ test("buildAcpModelSelectOptions fusiona aliases y formatea ACP", () => {
46
+ const { options } = buildAcpModelSelectOptions(
47
+ {
48
+ currentModel: "default",
49
+ models: [
50
+ { value: "default", name: "Default (recommended)" },
51
+ {
52
+ value: "claude-opus-5-5[context=300k,effort=medium,fast=false]",
53
+ name: "claude-opus-5-5",
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ options: [
59
+ { value: "", label: "Por defecto (opus)" },
60
+ { value: "opus", label: "opus", hint: "el más capaz" },
61
+ ],
62
+ }
63
+ );
64
+ assert.equal(options[0].label, "Por defecto (opus)");
65
+ assert.ok(options.some((o) => o.value === "opus"));
66
+ const opusAcp = options.find((o) => o.value.startsWith("claude-opus-5-5"));
67
+ assert.equal(opusAcp?.title, "Claude Opus 5.5");
68
+ assert.deepEqual(opusAcp?.badges, ["300k", "medium"]);
69
+ assert.equal(opusAcp?.hint, "[ 300k, medium ]");
70
+ });
71
+
72
+ test("modelOptionLabel compat", () => {
73
+ assert.equal(
74
+ modelOptionLabel("grok-4.7", "grok-4.7[context=256k,fast=true]"),
75
+ "Grok 4.7 · [ 256k, fast ]"
76
+ );
77
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Caché en disco de las listas de modelos ACP por agente.
3
+ * TTL 24h: los catálogos cambian de a poco (semanas); un día evita spawnear el CLI en cada review
4
+ * y sigue fresco si aparece un modelo nuevo.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+
10
+ /** 24 horas. */
11
+ export const MODELS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
12
+
13
+ /** @returns {string} */
14
+ export function modelsCachePath(home = homedir()) {
15
+ return join(home, ".diff-review", "models-cache.json");
16
+ }
17
+
18
+ /**
19
+ * @param {string} agentId
20
+ * @param {{ home?: string, now?: number, ttlMs?: number }} [opts]
21
+ * @returns {{ models: { value: string, name: string }[], currentModel: string, fetchedAt: string } | null}
22
+ */
23
+ export function readModelsCache(agentId, opts = {}) {
24
+ const id = String(agentId || "").trim();
25
+ if (!id) return null;
26
+ const path = modelsCachePath(opts.home);
27
+ let root;
28
+ try {
29
+ root = JSON.parse(readFileSync(path, "utf8"));
30
+ } catch {
31
+ return null;
32
+ }
33
+ const entry = root?.agents?.[id];
34
+ if (!entry || !Array.isArray(entry.models)) return null;
35
+ const fetchedAt = Date.parse(String(entry.fetchedAt || ""));
36
+ if (!Number.isFinite(fetchedAt)) return null;
37
+ const ttl = opts.ttlMs ?? MODELS_CACHE_TTL_MS;
38
+ const now = opts.now ?? Date.now();
39
+ if (now - fetchedAt > ttl) return null;
40
+ return {
41
+ models: entry.models
42
+ .filter((m) => m && typeof m.value === "string")
43
+ .map((m) => ({ value: m.value, name: String(m.name ?? m.value) })),
44
+ currentModel: String(entry.currentModel ?? ""),
45
+ fetchedAt: new Date(fetchedAt).toISOString(),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * @param {string} agentId
51
+ * @param {{ models: { value: string, name: string }[], currentModel?: string }} data
52
+ * @param {{ home?: string, now?: number }} [opts]
53
+ */
54
+ export function writeModelsCache(agentId, data, opts = {}) {
55
+ const id = String(agentId || "").trim();
56
+ if (!id || !Array.isArray(data?.models) || !data.models.length) return;
57
+ const path = modelsCachePath(opts.home);
58
+ let root = { version: 1, agents: {} };
59
+ try {
60
+ if (existsSync(path)) root = { ...root, ...JSON.parse(readFileSync(path, "utf8")) };
61
+ } catch {
62
+ /* archivo nuevo */
63
+ }
64
+ if (!root.agents || typeof root.agents !== "object") root.agents = {};
65
+ root.agents[id] = {
66
+ fetchedAt: new Date(opts.now ?? Date.now()).toISOString(),
67
+ currentModel: String(data.currentModel ?? ""),
68
+ models: data.models.map((m) => ({
69
+ value: String(m.value),
70
+ name: String(m.name ?? m.value),
71
+ })),
72
+ };
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
75
+ }
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import {
7
+ MODELS_CACHE_TTL_MS,
8
+ modelsCachePath,
9
+ readModelsCache,
10
+ writeModelsCache,
11
+ } from "./models-cache.mjs";
12
+ import { sortModelSelectOptions } from "./model-label.mjs";
13
+
14
+ test("sortModelSelectOptions: default y auto primero, resto lexico", () => {
15
+ const sorted = sortModelSelectOptions([
16
+ { value: "zulu", label: "Zulu" },
17
+ { value: "auto", label: "Auto" },
18
+ { value: "alpha", label: "Alpha" },
19
+ { value: "", label: "Por defecto" },
20
+ { value: "bravo", label: "Bravo" },
21
+ ]);
22
+ assert.deepEqual(
23
+ sorted.map((o) => o.value),
24
+ ["", "auto", "alpha", "bravo", "zulu"]
25
+ );
26
+ });
27
+
28
+ test("models cache roundtrip + TTL", () => {
29
+ const home = mkdtempSync(join(tmpdir(), "diff-review-mc-"));
30
+ const now = Date.parse("2026-01-01T12:00:00.000Z");
31
+ writeModelsCache(
32
+ "claude",
33
+ {
34
+ models: [{ value: "opus", name: "Opus" }],
35
+ currentModel: "default",
36
+ },
37
+ { home, now }
38
+ );
39
+ assert.ok(readFileSync(modelsCachePath(home), "utf8").includes("opus"));
40
+
41
+ const hit = readModelsCache("claude", { home, now: now + 1000 });
42
+ assert.equal(hit?.currentModel, "default");
43
+ assert.equal(hit?.models[0].value, "opus");
44
+
45
+ const expired = readModelsCache("claude", {
46
+ home,
47
+ now: now + MODELS_CACHE_TTL_MS + 1,
48
+ });
49
+ assert.equal(expired, null);
50
+ });
@@ -11,6 +11,14 @@ export const OUTPUT_PREFIX = "diff-rev";
11
11
  export const OUTPUT_PREFIX_LEGACY = "diff-review-output";
12
12
  export const OUTPUT_PREFIX_REPORT = "diff-report";
13
13
 
14
+ /** Carpeta (en la raíz del repo revisado) donde va el JSON, el diff y la sesión del agente. */
15
+ export const OUTPUT_DIR = ".diff-review";
16
+
17
+ /** Path relativo a la raíz del repo, con `/` (el que se le da al agente): `.diff-review/<nombre>`. */
18
+ export function outputRelPath(filename) {
19
+ return `${OUTPUT_DIR}/${String(filename || "").replace(/^.*[\\/]/, "")}`;
20
+ }
21
+
14
22
  /** Nombre legacy fijo. */
15
23
  export const OUTPUT_FILENAME_LEGACY = `${OUTPUT_PREFIX_LEGACY}.json`;
16
24