@duckmind/dm-darwin-arm64 0.13.2 → 0.13.5

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 (36) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +5 -5
  3. package/dm +0 -0
  4. package/docs/settings.md +1 -0
  5. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  6. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  7. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  8. package/examples/extensions/custom-provider-qwen-cli/package.json +1 -1
  9. package/examples/extensions/with-deps/package-lock.json +2 -2
  10. package/examples/extensions/with-deps/package.json +1 -1
  11. package/extensions/.dm-extensions.json +15 -15
  12. package/extensions/dm-multicodex/README.md +3 -1
  13. package/extensions/dm-multicodex/account-manager.test.ts +3 -1
  14. package/extensions/dm-multicodex/account-manager.ts +27 -1
  15. package/extensions/dm-multicodex/commands.test.ts +1 -0
  16. package/extensions/dm-multicodex/commands.ts +81 -1
  17. package/extensions/dm-multicodex/index.ts +7 -0
  18. package/extensions/dm-multicodex/node_modules/.package-lock.json +28 -28
  19. package/extensions/dm-multicodex/package-lock.json +11145 -9633
  20. package/extensions/dm-multicodex/package.json +56 -56
  21. package/extensions/dm-multicodex/storage.ts +2 -2
  22. package/extensions/dm-multicodex/sync.test.ts +114 -0
  23. package/extensions/dm-multicodex/sync.ts +249 -0
  24. package/extensions/dm-multicodex/tsconfig.json +17 -0
  25. package/extensions/dm-subagents/async-execution.ts +2 -0
  26. package/extensions/dm-subagents/execution.ts +1 -1
  27. package/extensions/dm-subagents/intercom-bridge.ts +8 -0
  28. package/extensions/dm-subagents/package.json +1 -1
  29. package/extensions/dm-subagents/skills.ts +117 -25
  30. package/extensions/dm-subagents/subagent-executor.ts +2 -6
  31. package/extensions/dm-subagents/subagent-runner.ts +10 -2
  32. package/extensions/dm-subagents/worktree.ts +27 -9
  33. package/extensions/dm-thinking-timer/README.md +1 -1
  34. package/extensions/dm-ultrathink/src/naming.ts +151 -10
  35. package/package.json +1 -1
  36. package/theme/duckmind.json +77 -65
@@ -6,7 +6,6 @@ import { execSync } from "node:child_process";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { loadSkills, type Skill } from "@mariozechner/pi-coding-agent";
10
9
 
11
10
  export type SkillSource =
12
11
  | "project"
@@ -86,6 +85,7 @@ function getPackageSkillPaths(packageRoot: string): string[] {
86
85
  .filter((s: unknown) => typeof s === "string")
87
86
  .map((s: string) => path.resolve(packageRoot, s));
88
87
  } catch {
88
+ // Package scanning is opportunistic; ignore malformed/missing package metadata.
89
89
  return [];
90
90
  }
91
91
  }
@@ -98,6 +98,7 @@ function getGlobalNpmRoot(): string | null {
98
98
  cachedGlobalNpmRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
99
99
  return cachedGlobalNpmRoot;
100
100
  } catch {
101
+ // Global npm root is optional in constrained environments.
101
102
  cachedGlobalNpmRoot = ""; // Empty string means "tried but failed"
102
103
  return null;
103
104
  }
@@ -123,6 +124,7 @@ function collectPackageSkillPaths(cwd: string): string[] {
123
124
  try {
124
125
  entries = fs.readdirSync(dir, { withFileTypes: true });
125
126
  } catch {
127
+ // Ignore unreadable package roots and continue scanning other roots.
126
128
  continue;
127
129
  }
128
130
 
@@ -136,6 +138,7 @@ function collectPackageSkillPaths(cwd: string): string[] {
136
138
  try {
137
139
  scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
138
140
  } catch {
141
+ // Ignore unreadable scoped package directories and continue.
139
142
  continue;
140
143
  }
141
144
  for (const scopeEntry of scopeEntries) {
@@ -178,7 +181,9 @@ function collectSettingsSkillPaths(cwd: string): string[] {
178
181
  }
179
182
  results.push(resolved);
180
183
  }
181
- } catch {}
184
+ } catch {
185
+ // Settings-provided skills are optional; ignore malformed or missing settings files.
186
+ }
182
187
  }
183
188
 
184
189
  return results;
@@ -196,21 +201,22 @@ function buildSkillPaths(cwd: string): string[] {
196
201
  return [...new Set([...defaultSkillPaths, ...packagePaths, ...settingsPaths])];
197
202
  }
198
203
 
199
- function inferSkillSource(sourceInfo: { source: string; scope: string }, filePath: string, cwd: string): SkillSource {
200
- const { scope, source } = sourceInfo;
201
-
202
- if (scope === "project" && source === "local") return "project";
203
- if (scope === "user" && source === "local") return "user";
204
+ function inferSkillSource(filePath: string, cwd: string): SkillSource {
205
+ const projectConfigRoot = path.resolve(cwd, CONFIG_DIR);
206
+ const projectSkillsRoot = path.resolve(cwd, CONFIG_DIR, "skills");
207
+ const projectPackagesRoot = path.resolve(cwd, CONFIG_DIR, "npm", "node_modules");
208
+ const projectAgentsRoot = path.resolve(cwd, ".agents");
209
+ const userSkillsRoot = path.resolve(AGENT_DIR, "skills");
210
+ const userPackagesRoot = path.resolve(AGENT_DIR, "npm", "node_modules");
211
+ const userAgentsRoot = path.resolve(os.homedir(), ".agents");
204
212
 
205
- // Fallback: infer from file path when sourceInfo isn't specific enough
206
- // (e.g. scope === "temporary" for skills loaded via explicit skillPaths)
207
- const projectRoot = path.resolve(cwd, CONFIG_DIR);
208
- const altProjectRoot = path.resolve(cwd, ".agents");
209
- const isProjectScoped = isWithinPath(filePath, projectRoot) || isWithinPath(filePath, altProjectRoot);
210
- if (isProjectScoped) return "project";
213
+ if (isWithinPath(filePath, projectPackagesRoot)) return "project-package";
214
+ if (isWithinPath(filePath, projectSkillsRoot) || isWithinPath(filePath, projectAgentsRoot)) return "project";
215
+ if (isWithinPath(filePath, projectConfigRoot)) return "project-settings";
211
216
 
212
- const isUserScoped = isWithinPath(filePath, AGENT_DIR) || isWithinPath(filePath, path.join(os.homedir(), ".agents"));
213
- if (isUserScoped) return "user";
217
+ if (isWithinPath(filePath, userPackagesRoot)) return "user-package";
218
+ if (isWithinPath(filePath, userSkillsRoot) || isWithinPath(filePath, userAgentsRoot)) return "user";
219
+ if (isWithinPath(filePath, AGENT_DIR)) return "user-settings";
214
220
 
215
221
  const globalRoot = getGlobalNpmRoot();
216
222
  if (globalRoot && isWithinPath(filePath, globalRoot)) return "user-package";
@@ -227,6 +233,99 @@ function chooseHigherPrioritySkill(existing: CachedSkillEntry | undefined, candi
227
233
  return candidate.order < existing.order ? candidate : existing;
228
234
  }
229
235
 
236
+ function maybeReadSkillDescription(filePath: string): string | undefined {
237
+ try {
238
+ const content = fs.readFileSync(filePath, "utf-8");
239
+ const normalized = content.replace(/\r\n/g, "\n");
240
+ if (!normalized.startsWith("---")) return undefined;
241
+
242
+ const endIndex = normalized.indexOf("\n---", 3);
243
+ if (endIndex === -1) return undefined;
244
+
245
+ const frontmatter = normalized.slice(3, endIndex).trim();
246
+ const match = frontmatter.match(/^description:\s*(.+)$/m);
247
+ if (!match) return undefined;
248
+ return match[1]?.trim().replace(/^['\"]|['\"]$/g, "");
249
+ } catch {
250
+ // Description parsing is best-effort metadata extraction.
251
+ return undefined;
252
+ }
253
+ }
254
+
255
+ function collectFilesystemSkills(cwd: string, skillPaths: string[]): CachedSkillEntry[] {
256
+ const entries: CachedSkillEntry[] = [];
257
+ const seen = new Set<string>();
258
+ let order = 0;
259
+
260
+ const pushEntry = (name: string, filePath: string) => {
261
+ const resolvedFile = path.resolve(filePath);
262
+ if (seen.has(resolvedFile)) return;
263
+ if (!fs.existsSync(resolvedFile)) return;
264
+ seen.add(resolvedFile);
265
+ entries.push({
266
+ name,
267
+ filePath: resolvedFile,
268
+ source: inferSkillSource(resolvedFile, cwd),
269
+ description: maybeReadSkillDescription(resolvedFile),
270
+ order: order++,
271
+ });
272
+ };
273
+
274
+ for (const skillPath of skillPaths) {
275
+ if (!fs.existsSync(skillPath)) continue;
276
+
277
+ let stat: fs.Stats;
278
+ try {
279
+ stat = fs.statSync(skillPath);
280
+ } catch {
281
+ // Ignore paths that disappear or become unreadable during discovery.
282
+ continue;
283
+ }
284
+
285
+ if (stat.isFile()) {
286
+ const fileName = path.basename(skillPath);
287
+ if (!fileName.toLowerCase().endsWith(".md")) continue;
288
+ const skillName = fileName.toLowerCase() === "skill.md"
289
+ ? path.basename(path.dirname(skillPath))
290
+ : path.basename(fileName, path.extname(fileName));
291
+ pushEntry(skillName, skillPath);
292
+ continue;
293
+ }
294
+
295
+ if (!stat.isDirectory()) continue;
296
+
297
+ const rootSkillFile = path.join(skillPath, "SKILL.md");
298
+ if (fs.existsSync(rootSkillFile)) {
299
+ pushEntry(path.basename(skillPath), rootSkillFile);
300
+ }
301
+
302
+ let childEntries: fs.Dirent[];
303
+ try {
304
+ childEntries = fs.readdirSync(skillPath, { withFileTypes: true });
305
+ } catch {
306
+ // Ignore unreadable skill directories and continue scanning.
307
+ continue;
308
+ }
309
+
310
+ for (const child of childEntries) {
311
+ if (child.name.startsWith(".")) continue;
312
+ const childPath = path.join(skillPath, child.name);
313
+ if (child.isDirectory() || child.isSymbolicLink()) {
314
+ const nestedSkillPath = path.join(childPath, "SKILL.md");
315
+ if (fs.existsSync(nestedSkillPath)) {
316
+ pushEntry(child.name, nestedSkillPath);
317
+ }
318
+ continue;
319
+ }
320
+ if (child.isFile() && child.name.toLowerCase().endsWith(".md")) {
321
+ pushEntry(path.basename(child.name, path.extname(child.name)), childPath);
322
+ }
323
+ }
324
+ }
325
+
326
+ return entries;
327
+ }
328
+
230
329
  function getCachedSkills(cwd: string): CachedSkillEntry[] {
231
330
  const now = Date.now();
232
331
  if (loadSkillsCache && loadSkillsCache.cwd === cwd && now - loadSkillsCache.timestamp < LOAD_SKILLS_CACHE_TTL_MS) {
@@ -234,18 +333,10 @@ function getCachedSkills(cwd: string): CachedSkillEntry[] {
234
333
  }
235
334
 
236
335
  const skillPaths = buildSkillPaths(cwd);
237
- const loaded = loadSkills({ cwd, skillPaths, includeDefaults: false });
336
+ const loaded = collectFilesystemSkills(cwd, skillPaths);
238
337
  const dedupedByName = new Map<string, CachedSkillEntry>();
239
338
 
240
- for (let i = 0; i < loaded.skills.length; i++) {
241
- const skill = loaded.skills[i] as Skill;
242
- const entry: CachedSkillEntry = {
243
- name: skill.name,
244
- filePath: skill.filePath,
245
- source: inferSkillSource(skill.sourceInfo, skill.filePath, cwd),
246
- description: skill.description,
247
- order: i,
248
- };
339
+ for (const entry of loaded) {
249
340
  const current = dedupedByName.get(entry.name);
250
341
  dedupedByName.set(entry.name, chooseHigherPrioritySkill(current, entry));
251
342
  }
@@ -294,6 +385,7 @@ export function readSkill(
294
385
 
295
386
  return skill;
296
387
  } catch {
388
+ // Treat unreadable skill files as unresolved so callers can surface as missing.
297
389
  return undefined;
298
390
  }
299
391
  }
@@ -22,7 +22,7 @@ import {
22
22
  import { discoverAvailableSkills, normalizeSkillInput } from "./skills.ts";
23
23
  import { executeAsyncChain, executeAsyncSingle, isAsyncAvailable } from "./async-execution.ts";
24
24
  import { createForkContextResolver } from "./fork-context.ts";
25
- import { applyIntercomBridgeToAgent, resolveIntercomBridge } from "./intercom-bridge.ts";
25
+ import { applyIntercomBridgeToAgent, resolveIntercomBridge, resolveIntercomSessionTarget } from "./intercom-bridge.ts";
26
26
  import { finalizeSingleOutput, injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.ts";
27
27
  import { getSingleResultOutput, mapConcurrent } from "./utils.ts";
28
28
  import {
@@ -1117,11 +1117,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
1117
1117
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
1118
1118
  deps.state.currentSessionId = parentSessionFile ?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1119
1119
  const discoveredAgents = deps.discoverAgents(ctx.cwd, scope).agents;
1120
- let sessionName = deps.pi.getSessionName()?.trim();
1121
- if (!sessionName) {
1122
- sessionName = `session-${ctx.sessionManager.getSessionId().slice(0, 8)}`;
1123
- deps.pi.setSessionName(sessionName);
1124
- }
1120
+ const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
1125
1121
  const intercomBridge = resolveIntercomBridge({
1126
1122
  config: deps.config.intercomBridge,
1127
1123
  context: normalizedParams.context,
@@ -53,6 +53,7 @@ interface SubagentRunConfig {
53
53
  asyncDir: string;
54
54
  sessionId?: string | null;
55
55
  piPackageRoot?: string;
56
+ piArgv1?: string;
56
57
  worktreeSetupHook?: string;
57
58
  worktreeSetupHookTimeoutMs?: number;
58
59
  }
@@ -156,12 +157,16 @@ function runPiStreaming(
156
157
  outputFile: string,
157
158
  env?: Record<string, string | undefined>,
158
159
  piPackageRoot?: string,
160
+ piArgv1?: string,
159
161
  maxSubagentDepth?: number,
160
162
  ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
161
163
  return new Promise((resolve) => {
162
164
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
163
165
  const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv(maxSubagentDepth) };
164
- const spawnSpec = getPiSpawnCommand(args, piPackageRoot ? { piPackageRoot } : undefined);
166
+ const spawnSpec = getPiSpawnCommand(args, {
167
+ ...(piPackageRoot ? { piPackageRoot } : {}),
168
+ ...(piArgv1 ? { argv1: piArgv1 } : {}),
169
+ });
165
170
  const child = spawn(spawnSpec.command, spawnSpec.args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: spawnEnv });
166
171
  let stdout = "";
167
172
  let stderr = "";
@@ -349,6 +354,7 @@ interface SingleStepContext {
349
354
  flatStepCount: number;
350
355
  outputFile: string;
351
356
  piPackageRoot?: string;
357
+ piArgv1?: string;
352
358
  }
353
359
 
354
360
  /** Run a single pi agent step, returning output and metadata */
@@ -410,7 +416,7 @@ async function runSingleStep(
410
416
  promptFileStem: step.agent,
411
417
  });
412
418
  const outputFile = index === 0 ? ctx.outputFile : `${ctx.outputFile}.attempt-${index + 1}`;
413
- const run = await runPiStreaming(args, step.cwd ?? ctx.cwd, outputFile, env, ctx.piPackageRoot, step.maxSubagentDepth);
419
+ const run = await runPiStreaming(args, step.cwd ?? ctx.cwd, outputFile, env, ctx.piPackageRoot, ctx.piArgv1, step.maxSubagentDepth);
414
420
  cleanupTempDir(tempDir);
415
421
 
416
422
  const parsed = parseRunOutput(run.stdout);
@@ -768,6 +774,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
768
774
  flatIndex: fi, flatStepCount: flatSteps.length,
769
775
  outputFile: path.join(asyncDir, `output-${fi}.log`),
770
776
  piPackageRoot: config.piPackageRoot,
777
+ piArgv1: config.piArgv1,
771
778
  });
772
779
  if (task.sessionFile) {
773
780
  latestSessionFile = task.sessionFile;
@@ -888,6 +895,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
888
895
  flatIndex, flatStepCount: flatSteps.length,
889
896
  outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
890
897
  piPackageRoot: config.piPackageRoot,
898
+ piArgv1: config.piArgv1,
891
899
  });
892
900
  if (seqStep.sessionFile) {
893
901
  latestSessionFile = seqStep.sessionFile;
@@ -106,9 +106,11 @@ function resolveRepoState(cwd: string): RepoState {
106
106
  }
107
107
 
108
108
  const toplevel = runGitChecked(cwd, ["rev-parse", "--show-toplevel"]).trim();
109
- const realCwd = fs.realpathSync(cwd);
110
- const realToplevel = fs.realpathSync(toplevel);
111
- const cwdRelative = path.relative(realToplevel, realCwd);
109
+ const rawPrefix = runGitChecked(cwd, ["rev-parse", "--show-prefix"]).trim();
110
+ const normalizedPrefix = rawPrefix
111
+ ? path.normalize(rawPrefix.replace(/[\\/]+$/, ""))
112
+ : "";
113
+ const cwdRelative = normalizedPrefix === "." ? "" : normalizedPrefix;
112
114
 
113
115
  const status = runGitChecked(toplevel, ["status", "--porcelain"]);
114
116
  if (status.trim().length > 0) {
@@ -124,6 +126,7 @@ function normalizeComparableCwd(cwd: string): string {
124
126
  try {
125
127
  return fs.realpathSync(resolved);
126
128
  } catch {
129
+ // Use the unresolved absolute path when realpath resolution is unavailable.
127
130
  return resolved;
128
131
  }
129
132
  }
@@ -169,6 +172,7 @@ function linkNodeModulesIfPresent(toplevel: string, worktreePath: string): boole
169
172
  fs.symlinkSync(nodeModulesPath, nodeModulesLinkPath);
170
173
  return true;
171
174
  } catch {
175
+ // Symlink creation is optional (e.g., unsupported filesystems on CI runners).
172
176
  return false;
173
177
  }
174
178
  }
@@ -344,8 +348,12 @@ function createSingleWorktree(
344
348
  syntheticPaths,
345
349
  };
346
350
  } catch (error) {
347
- try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {}
348
- try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {}
351
+ try { runGitChecked(toplevel, ["worktree", "remove", "--force", worktreePath]); } catch {
352
+ // Best-effort rollback; preserve the original setup failure.
353
+ }
354
+ try { runGitChecked(toplevel, ["branch", "-D", branch]); } catch {
355
+ // Best-effort rollback; preserve the original setup failure.
356
+ }
349
357
  throw error;
350
358
  }
351
359
  }
@@ -453,12 +461,18 @@ function captureWorktreeDiff(
453
461
  function writeEmptyPatch(patchPath: string): void {
454
462
  try {
455
463
  fs.writeFileSync(patchPath, "", "utf-8");
456
- } catch {}
464
+ } catch {
465
+ // Diff artifact writing is best-effort in error paths.
466
+ }
457
467
  }
458
468
 
459
469
  function cleanupSingleWorktree(repoCwd: string, worktree: WorktreeInfo): void {
460
- try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {}
461
- try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {}
470
+ try { runGitChecked(repoCwd, ["worktree", "remove", "--force", worktree.path]); } catch {
471
+ // Cleanup is best-effort to avoid masking caller errors.
472
+ }
473
+ try { runGitChecked(repoCwd, ["branch", "-D", worktree.branch]); } catch {
474
+ // Cleanup is best-effort to avoid masking caller errors.
475
+ }
462
476
  }
463
477
 
464
478
  function hasWorktreeChanges(diff: WorktreeDiff): boolean {
@@ -502,6 +516,7 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
502
516
  try {
503
517
  fs.mkdirSync(diffsDir, { recursive: true });
504
518
  } catch {
519
+ // Returning no diffs is safer than failing the whole command on artifact-dir issues.
505
520
  return [];
506
521
  }
507
522
 
@@ -513,6 +528,7 @@ export function diffWorktrees(setup: WorktreeSetup, agents: string[], diffsDir:
513
528
  try {
514
529
  diffs.push(captureWorktreeDiff(setup, worktree, agent, patchPath));
515
530
  } catch {
531
+ // Preserve execution flow; failed diff capture maps to an empty per-task patch.
516
532
  writeEmptyPatch(patchPath);
517
533
  diffs.push(emptyDiff(index, agent, worktree.branch, patchPath));
518
534
  }
@@ -525,7 +541,9 @@ export function cleanupWorktrees(setup: WorktreeSetup): void {
525
541
  for (let index = setup.worktrees.length - 1; index >= 0; index--) {
526
542
  cleanupSingleWorktree(setup.cwd, setup.worktrees[index]!);
527
543
  }
528
- try { runGitChecked(setup.cwd, ["worktree", "prune"]); } catch {}
544
+ try { runGitChecked(setup.cwd, ["worktree", "prune"]); } catch {
545
+ // Pruning is best-effort cleanup.
546
+ }
529
547
  }
530
548
 
531
549
  export function formatWorktreeDiffSummary(diffs: WorktreeDiff[]): string {
@@ -4,4 +4,4 @@ Bundled DM extension that shows a live timer next to collapsed Thinking blocks.
4
4
 
5
5
  ## Bundled with dm
6
6
 
7
- No separate install step is required. Install `dm`, restart the TUI or run `/reload`, and the timer patch is available.
7
+ No separate install step is required. Install `dm` and restart the TUI if the timer patch is not already active.
@@ -45,6 +45,55 @@ type NamingTestOverrides = {
45
45
  generateMergeCommitMessage?: (args: GenerateMergeCommitMessageArgs) => Promise<GeneratedCommitMessage>;
46
46
  };
47
47
 
48
+ type NamingModelChoice = {
49
+ label: string;
50
+ config: NamingModelConfig;
51
+ };
52
+
53
+ type DuckMindPresetAlias = "free" | "auto" | "lite" | "smart" | "deep";
54
+
55
+ type DuckMindPreset = {
56
+ alias: DuckMindPresetAlias;
57
+ envKey: string;
58
+ fallbackModelId: string;
59
+ displayName: string;
60
+ };
61
+
62
+ const DUCKMIND_PROVIDER = "duckmind";
63
+ const OPENROUTER_PROVIDER = "openrouter";
64
+ const DUCKMIND_PRESETS: readonly DuckMindPreset[] = [
65
+ {
66
+ alias: "free",
67
+ envKey: "DM_FREE_MODEL",
68
+ fallbackModelId: "google/gemini-3.1-flash-lite-preview",
69
+ displayName: "DuckMind Free",
70
+ },
71
+ {
72
+ alias: "auto",
73
+ envKey: "DM_AUTO_MODEL",
74
+ fallbackModelId: "auto",
75
+ displayName: "DuckMind Auto",
76
+ },
77
+ {
78
+ alias: "lite",
79
+ envKey: "DM_LITE_MODEL",
80
+ fallbackModelId: "google/gemini-2.5-flash",
81
+ displayName: "DuckMind Lite",
82
+ },
83
+ {
84
+ alias: "smart",
85
+ envKey: "DM_SMART_MODEL",
86
+ fallbackModelId: "anthropic/claude-sonnet-4.5",
87
+ displayName: "DuckMind Smart",
88
+ },
89
+ {
90
+ alias: "deep",
91
+ envKey: "DM_DEEP_MODEL",
92
+ fallbackModelId: "openai/o3",
93
+ displayName: "DuckMind Deep",
94
+ },
95
+ ];
96
+
48
97
  let testOverrides: NamingTestOverrides | undefined;
49
98
 
50
99
  export function setNamingTestOverrides(overrides?: NamingTestOverrides): void {
@@ -102,8 +151,90 @@ function normalizeCommitMessage(message: GeneratedCommitMessage): GeneratedCommi
102
151
  return { subject, body };
103
152
  }
104
153
 
154
+ function resolveDuckMindPresetModelId(envKey: string, fallbackModelId: string): string {
155
+ const override = process.env[envKey]?.trim();
156
+ return override ? override : fallbackModelId;
157
+ }
158
+
159
+ function pickOpenRouterModel(
160
+ models: Model<any>[],
161
+ actualModelId: string,
162
+ fallbackModelId: string,
163
+ displayName: string,
164
+ ): Model<any> | undefined {
165
+ if (models.length === 0) return undefined;
166
+
167
+ const exact = models.find((model) => model.id.toLowerCase() === actualModelId.toLowerCase());
168
+ if (exact) {
169
+ return exact;
170
+ }
171
+
172
+ const fallback =
173
+ models.find((model) => model.id.toLowerCase() === fallbackModelId.toLowerCase()) ?? models[0];
174
+ return {
175
+ ...fallback,
176
+ id: actualModelId,
177
+ name: displayName,
178
+ };
179
+ }
180
+
181
+ function buildDefaultNamingChoice(model: Model<any>): NamingModelChoice {
182
+ return {
183
+ label: `${model.provider}/${model.id}`,
184
+ config: {
185
+ provider: model.provider,
186
+ modelId: model.id,
187
+ },
188
+ };
189
+ }
190
+
191
+ function buildDuckMindNamingChoices(availableModels: Model<any>[]): NamingModelChoice[] {
192
+ const openrouterModels = availableModels.filter((model) => model.provider === OPENROUTER_PROVIDER);
193
+ return DUCKMIND_PRESETS.flatMap((preset) => {
194
+ const actualModelId = resolveDuckMindPresetModelId(preset.envKey, preset.fallbackModelId);
195
+ const model = pickOpenRouterModel(openrouterModels, actualModelId, preset.fallbackModelId, preset.displayName);
196
+ if (!model) {
197
+ return [];
198
+ }
199
+ return [
200
+ {
201
+ label: `${DUCKMIND_PROVIDER}/${preset.alias}`,
202
+ config: {
203
+ provider: DUCKMIND_PROVIDER,
204
+ modelId: preset.alias,
205
+ },
206
+ },
207
+ ];
208
+ });
209
+ }
210
+
211
+ function resolveDuckMindAliasModel(availableModels: Model<any>[], config: NamingModelConfig): Model<any> | undefined {
212
+ const normalizedProvider = config.provider.trim().toLowerCase();
213
+ const normalizedModelId = config.modelId.trim().toLowerCase();
214
+ if (normalizedProvider !== DUCKMIND_PROVIDER && normalizedProvider !== OPENROUTER_PROVIDER) {
215
+ return undefined;
216
+ }
217
+
218
+ const preset = DUCKMIND_PRESETS.find(
219
+ (entry) => normalizedModelId === entry.alias || normalizedModelId === `@preset/${entry.alias}`,
220
+ );
221
+ if (!preset) {
222
+ return undefined;
223
+ }
224
+
225
+ const actualModelId = resolveDuckMindPresetModelId(preset.envKey, preset.fallbackModelId);
226
+ return pickOpenRouterModel(
227
+ availableModels.filter((model) => model.provider === OPENROUTER_PROVIDER),
228
+ actualModelId,
229
+ preset.fallbackModelId,
230
+ preset.displayName,
231
+ );
232
+ }
233
+
105
234
  async function resolveNamingModel(ctx: ExtensionContext, config: NamingModelConfig): Promise<{ model: Model<any>; apiKey: string }> {
106
- const model = ctx.modelRegistry.find(config.provider, config.modelId);
235
+ const model =
236
+ resolveDuckMindAliasModel(ctx.modelRegistry.getAvailable(), config) ??
237
+ ctx.modelRegistry.find(config.provider, config.modelId);
107
238
  if (!model) {
108
239
  throw new Error(`Ultrathink naming model ${config.provider}/${config.modelId} is not available in DM.`);
109
240
  }
@@ -149,14 +280,19 @@ function formatModelOption(model: Model<any>): string {
149
280
  return `${model.provider}/${model.id}`;
150
281
  }
151
282
 
152
- function parseModelOption(value: string): NamingModelConfig {
153
- const slash = value.indexOf("/");
154
- if (slash <= 0 || slash === value.length - 1) {
155
- throw new Error(`Invalid model option: ${value}`);
283
+ function resolveNamingChoice(selection: string, choices: NamingModelChoice[]): NamingModelConfig {
284
+ const choice = choices.find((entry) => entry.label === selection);
285
+ if (choice) {
286
+ return choice.config;
287
+ }
288
+
289
+ const slash = selection.indexOf("/");
290
+ if (slash <= 0 || slash === selection.length - 1) {
291
+ throw new Error(`Invalid model option: ${selection}`);
156
292
  }
157
293
  return {
158
- provider: value.slice(0, slash),
159
- modelId: value.slice(slash + 1),
294
+ provider: selection.slice(0, slash),
295
+ modelId: selection.slice(slash + 1),
160
296
  };
161
297
  }
162
298
 
@@ -181,20 +317,25 @@ export async function ensureNamingModel(
181
317
  .filter((model) => model.input.includes("text"))
182
318
  .sort((a, b) => formatModelOption(a).localeCompare(formatModelOption(b)));
183
319
 
184
- if (availableModels.length === 0) {
320
+ const choices =
321
+ ctx.model?.provider === OPENROUTER_PROVIDER || ctx.model?.provider === DUCKMIND_PROVIDER
322
+ ? buildDuckMindNamingChoices(availableModels)
323
+ : availableModels.map(buildDefaultNamingChoice);
324
+
325
+ if (choices.length === 0) {
185
326
  throw new Error("Ultrathink could not find any available DM models to use for branch and commit naming.");
186
327
  }
187
328
 
188
329
  const selection = await ctx.ui.select(
189
330
  "Choose the small model Ultrathink should use for branch names and commit descriptions",
190
- availableModels.map(formatModelOption),
331
+ choices.map((choice) => choice.label),
191
332
  );
192
333
 
193
334
  if (!selection) {
194
335
  return null;
195
336
  }
196
337
 
197
- const namingModel = parseModelOption(selection);
338
+ const namingModel = resolveNamingChoice(selection, choices);
198
339
  await saveUltrathinkNamingConfig(namingModel);
199
340
  return namingModel;
200
341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duckmind/dm-darwin-arm64",
3
- "version": "0.13.2",
3
+ "version": "0.13.5",
4
4
  "description": "DuckMind (dm) binary payload for darwin arm64",
5
5
  "license": "MIT",
6
6
  "os": [