@hunterzhu/pulse-cli 0.5.0 → 0.5.1

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/dist/bin.js CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { assertJarvisApiUrl, createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
7
+ import { modelSelectionId, normalizeModelConfig, resolveModelReference } from '@hunterzhu/pulse-server';
7
8
  import { defaultPulseConfig, defaultPulseConfigPath, ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
8
9
  import { runInteractive } from './commands/interactive.js';
9
10
  import { runOneShot } from './commands/run.js';
@@ -162,7 +163,7 @@ export async function hostOptions(parsed) {
162
163
  }
163
164
  const loaded = (await loadPulseConfig(requestedCwd ?? process.cwd(), explicitConfig, parsed.options['trust-workspace'] === true)).value;
164
165
  // A missing user config still has to answer usage errors and list sessions.
165
- const config = loaded.activeModel || loaded.models || loaded.providers ? loaded : { ...defaultPulseConfig, ...loaded };
166
+ const config = normalizeModelConfig(loaded.activeModel || loaded.models || loaded.providers ? loaded : { ...defaultPulseConfig, ...loaded });
166
167
  const mockResponse = option(parsed.options, 'mock-response');
167
168
  const mockTaskAssessment = option(parsed.options, 'mock-task-assessment');
168
169
  const mockStageAssessment = option(parsed.options, 'mock-stage-assessment');
@@ -171,25 +172,33 @@ export async function hostOptions(parsed) {
171
172
  const mockParallelPlanOption = option(parsed.options, 'mock-parallel-plan');
172
173
  const hasMockDemand = mockResponse !== undefined || mockTaskAssessment !== undefined || mockStageAssessment !== undefined || mockToolCallsOption !== undefined || mockAfterToolResponse !== undefined || mockParallelPlanOption !== undefined;
173
174
  const cliModel = option(parsed.options, 'model');
174
- const requestedModel = cliModel ?? (hasMockDemand ? 'mock' : process.env.PULSE_MODEL ?? config.activeModel);
175
- const effectiveModels = { ...(config.models ?? {}) };
175
+ const requestedModel = cliModel ?? (hasMockDemand ? 'mock/mock' : process.env.PULSE_MODEL ?? config.activeModel);
176
176
  const effectiveProviders = { ...(config.providers ?? {}) };
177
- if (requestedModel === 'mock' || (hasMockDemand && cliModel === undefined)) {
178
- if (!effectiveModels.mock) {
179
- effectiveModels.mock = { displayName: 'mock', provider: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' };
180
- }
177
+ if (requestedModel === 'mock/mock' || requestedModel === 'mock' || (hasMockDemand && cliModel === undefined)) {
181
178
  if (!effectiveProviders.mock) {
182
179
  effectiveProviders.mock = { provider: 'mock', name: 'Mock' };
183
180
  }
181
+ effectiveProviders.mock.models ??= {};
182
+ effectiveProviders.mock.models.mock ??= { displayName: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' };
183
+ }
184
+ const catalog = { ...config, providers: effectiveProviders };
185
+ const selections = Object.entries(effectiveProviders).flatMap(([providerId, profile]) => Object.entries(profile.models ?? {}).map(([modelId, model]) => ({ id: modelSelectionId(providerId, modelId), providerId, modelId, model })));
186
+ if (!requestedModel)
187
+ throw new Error('UNKNOWN_MODEL_DISPLAY_NAME:(missing)');
188
+ let activeModelName;
189
+ try {
190
+ activeModelName = resolveModelReference(catalog, requestedModel);
184
191
  }
185
- const configuredModelEntry = requestedModel === undefined
186
- ? undefined
187
- : Object.entries(effectiveModels).find(([name, item]) => name === requestedModel || item.displayName === requestedModel);
192
+ catch (error) {
193
+ if (error instanceof Error && error.message.startsWith('AMBIGUOUS_MODEL_REFERENCE:'))
194
+ throw error;
195
+ throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel}`);
196
+ }
197
+ const configuredModelEntry = selections.find(item => item.id === activeModelName);
188
198
  if (!configuredModelEntry)
189
- throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel ?? '(missing)'}`);
190
- const [, modelSelection] = configuredModelEntry;
191
- const activeModelName = modelSelection.displayName;
192
- const providerName = modelSelection.provider;
199
+ throw new Error(`UNKNOWN_MODEL_DISPLAY_NAME:${requestedModel}`);
200
+ const modelSelection = configuredModelEntry.model;
201
+ const providerName = configuredModelEntry.providerId;
193
202
  const profile = effectiveProviders[providerName];
194
203
  if (!profile)
195
204
  throw new Error(`MODEL_PROVIDER_NOT_FOUND:${providerName}`);
@@ -244,28 +253,17 @@ export async function hostOptions(parsed) {
244
253
  ...(item.toolChoice === undefined ? {} : { toolChoice: item.toolChoice }),
245
254
  }]];
246
255
  }));
247
- const modelDisplayNames = new Set();
248
- const providerModels = Object.fromEntries(Object.entries(effectiveModels).flatMap(([name, item]) => {
249
- if (!effectiveProviders[item.provider])
250
- throw new Error(`MODEL_PROVIDER_NOT_FOUND:${item.provider}`);
251
- const displayName = item.displayName.trim();
252
- if (!displayName)
253
- throw new Error(`MODEL_DISPLAY_NAME_REQUIRED:${name}`);
254
- if (modelDisplayNames.has(displayName))
255
- throw new Error(`DUPLICATE_MODEL_DISPLAY_NAME:${displayName}`);
256
- modelDisplayNames.add(displayName);
257
- return [[displayName, {
258
- provider: item.provider,
259
- model: item.modelCode,
260
- ...(item.maxContextTokens === undefined ? {} : { maxContextTokens: item.maxContextTokens }),
261
- ...(item.maxOutputTokens === undefined ? {} : { maxOutputTokens: item.maxOutputTokens }),
262
- ...(item.reasoningEffort === undefined ? {} : { reasoningEffort: item.reasoningEffort }),
263
- }]];
264
- }));
265
- const assistantModelName = config.assistantModel ?? activeModelName;
256
+ const providerModels = Object.fromEntries(selections.map(({ id, providerId, model }) => [id, {
257
+ provider: providerId,
258
+ model: model.modelCode,
259
+ ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
260
+ ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
261
+ ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
262
+ }]));
263
+ const assistantModelName = config.assistantModel ? resolveModelReference(catalog, config.assistantModel) : activeModelName;
266
264
  if (!providerModels[assistantModelName])
267
265
  throw new Error(`UNKNOWN_ASSISTANT_MODEL:${assistantModelName}`);
268
- const taskRouting = config.taskRouting;
266
+ const taskRouting = config.taskRouting && Object.fromEntries(Object.entries(config.taskRouting).map(([task, route]) => [task, Array.isArray(route) ? route.map(item => resolveModelReference(catalog, item)) : route]));
269
267
  for (const [task, models] of Object.entries(taskRouting ?? {})) {
270
268
  if (!Array.isArray(models) || models.length === 0 || models.some((model) => typeof model !== 'string' || !providerModels[model])) {
271
269
  throw new Error(`INVALID_TASK_MODEL_ROUTE:${task}`);
@@ -425,7 +423,7 @@ export async function hostOptions(parsed) {
425
423
  provider,
426
424
  providerProfiles,
427
425
  providerModels,
428
- modelPricing: Object.fromEntries(Object.values(config.models ?? {}).flatMap((item) => item.pricing ? [[item.displayName, item.pricing]] : [])),
426
+ modelPricing: Object.fromEntries(selections.flatMap(({ id, model }) => model.pricing ? [[id, model.pricing]] : [])),
429
427
  ...(taskRouting === undefined ? {} : { taskRouting }),
430
428
  capabilityPacks,
431
429
  enabledCapabilityPacks,
@@ -446,8 +444,21 @@ export async function hostOptions(parsed) {
446
444
  ...(allowNetwork === undefined ? {} : { allowNetwork }),
447
445
  ...(config.networkHosts === undefined ? {} : { networkHosts: config.networkHosts }),
448
446
  jarvis,
449
- ...(config.frontModel ? { frontModel: config.frontModel } : {}),
450
- ...(config.approvalReview?.model ? { approvalReview: { provider: config.approvalReview.provider ?? 'ollama', model: config.approvalReview.model, baseURL: config.approvalReview.baseURL ?? 'http://127.0.0.1:11434', timeoutMs: Math.max(1_000, Math.min(60_000, config.approvalReview.timeoutMs ?? 15_000)) } } : {}),
447
+ ...(config.frontModel ? { frontModel: (() => {
448
+ const selected = typeof config.frontModel?.model === 'string' ? resolveModelReference(catalog, config.frontModel.model) : undefined;
449
+ const entry = selections.find(item => item.id === selected);
450
+ const source = entry && effectiveProviders[entry.providerId];
451
+ const keyEnv = source?.apiKeyEnv ?? (source?.provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY');
452
+ return { ...config.frontModel, ...(entry && source ? { provider: source.provider, model: entry.model.modelCode, ...(source.baseURL ? { baseURL: source.baseURL } : {}), ...(process.env[keyEnv] ? { apiKey: process.env[keyEnv] } : {}) } : {}) };
453
+ })() } : {}),
454
+ ...(config.approvalReview?.model ? { approvalReview: (() => {
455
+ const selected = resolveModelReference(catalog, config.approvalReview.model);
456
+ const entry = selections.find(item => item.id === selected);
457
+ const source = entry && effectiveProviders[entry.providerId];
458
+ if (!entry || !source || source.provider !== 'ollama')
459
+ throw new Error('APPROVAL_REVIEW_REQUIRES_OLLAMA');
460
+ return { provider: 'ollama', model: entry.model.modelCode, baseURL: source.baseURL ?? 'http://127.0.0.1:11434', timeoutMs: Math.max(1_000, Math.min(60_000, config.approvalReview.timeoutMs ?? 15_000)) };
461
+ })() } : {}),
451
462
  };
452
463
  }
453
464
  export async function main() {
package/dist/config.d.ts CHANGED
@@ -13,14 +13,16 @@ export interface PulseCliProviderProfile {
13
13
  name: string;
14
14
  };
15
15
  };
16
+ /** Models served by this provider. The provider plus map key is the model identity. */
17
+ models?: Record<string, Omit<PulseCliModel, 'provider'>>;
16
18
  }
17
19
  export interface PulseCliModel {
18
- /** Provider map key, not the adapter id. */
19
- provider: string;
20
+ /** Legacy flat-config field; nested models inherit their provider from the parent. */
21
+ provider?: string;
20
22
  /** Exact model identifier sent to the provider. */
21
23
  modelCode: string;
22
24
  /** Globally unique name shown by the CLI and accepted by `/model`. */
23
- displayName: string;
25
+ displayName?: string;
24
26
  maxContextTokens?: number;
25
27
  maxOutputTokens?: number;
26
28
  reasoningEffort?: 'low' | 'medium' | 'high';
@@ -61,13 +63,13 @@ export interface PulseCliConfig {
61
63
  systemPromptFile?: string;
62
64
  /** Named provider profiles. The map key is a stable provider code. */
63
65
  providers?: Record<string, PulseCliProviderProfile>;
64
- /** Globally unique Pulse model names mapped to provider/model codes. */
66
+ /** @deprecated Legacy flat model catalog. New configs nest models under providers. */
65
67
  models?: Record<string, PulseCliModel>;
66
- /** Active Pulse model display name. */
68
+ /** Active Pulse model reference, preferably providerId/modelId. */
67
69
  activeModel?: string;
68
70
  /** Dedicated model for the persistent Pulse assistant chat; defaults to activeModel. */
69
71
  assistantModel?: string;
70
- /** Ordered model display names per task; later entries act as fallbacks. */
72
+ /** Ordered providerId/modelId references per task; later entries act as fallbacks. */
71
73
  taskRouting?: Partial<Record<PulseModelTask, string[]>>;
72
74
  capabilities?: PulseCliCapabilities;
73
75
  approvalMode?: 'read-only' | 'ask' | 'auto';
package/dist/config.js CHANGED
@@ -3,26 +3,23 @@ import { homedir } from 'node:os';
3
3
  import { dirname, join, resolve, sep } from 'node:path';
4
4
  export const defaultPulseConfig = {
5
5
  providers: {
6
- mock: { provider: 'mock', name: 'Mock' },
6
+ mock: { provider: 'mock', name: 'Mock', models: { mock: { displayName: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' } } },
7
7
  openai: {
8
8
  provider: 'openai-compatible',
9
9
  name: 'OpenAI',
10
10
  baseURL: 'https://api.openai.com/v1',
11
11
  apiKeyEnv: 'OPENAI_API_KEY',
12
+ models: { 'gpt5.6-a': { displayName: 'gpt5.6-a', modelCode: 'gpt-5.6' } },
12
13
  },
13
14
  deepseek: {
14
15
  provider: 'deepseek',
15
16
  name: 'DeepSeek',
16
17
  baseURL: 'https://api.deepseek.com',
17
18
  apiKeyEnv: 'DEEPSEEK_API_KEY',
19
+ models: { 'deepseek-chat': { displayName: 'deepseek-chat', modelCode: 'deepseek-chat' } },
18
20
  },
19
21
  },
20
- models: {
21
- mock: { displayName: 'mock', provider: 'mock', modelCode: 'mock', maxContextTokens: 32_000, maxOutputTokens: 4_096, reasoningEffort: 'medium' },
22
- 'gpt5.6-a': { displayName: 'gpt5.6-a', provider: 'openai', modelCode: 'gpt-5.6' },
23
- 'deepseek-chat': { displayName: 'deepseek-chat', provider: 'deepseek', modelCode: 'deepseek-chat' },
24
- },
25
- activeModel: 'mock',
22
+ activeModel: 'mock/mock',
26
23
  approvalMode: 'ask',
27
24
  maxTurns: 32,
28
25
  autoCompactPercent: 90,
@@ -82,6 +79,13 @@ export function sanitizeWorkspaceConfig(value) {
82
79
  const safe = {
83
80
  provider: profile.provider,
84
81
  ...(profile.name === undefined ? {} : { name: profile.name }),
82
+ ...(profile.models === undefined ? {} : { models: Object.fromEntries(Object.entries(profile.models).flatMap(([modelId, model]) => model.modelCode ? [[modelId, {
83
+ modelCode: model.modelCode,
84
+ ...(model.displayName === undefined ? {} : { displayName: model.displayName }),
85
+ ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
86
+ ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
87
+ ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
88
+ }]] : [])) }),
85
89
  };
86
90
  return Object.keys(safe).length ? [[name, safe]] : [];
87
91
  }));
@@ -100,7 +104,7 @@ export function sanitizeWorkspaceConfig(value) {
100
104
  return [[name, {
101
105
  provider: model.provider,
102
106
  modelCode: model.modelCode,
103
- displayName: model.displayName,
107
+ ...(model.displayName === undefined ? {} : { displayName: model.displayName }),
104
108
  ...(model.maxContextTokens === undefined ? {} : { maxContextTokens: model.maxContextTokens }),
105
109
  ...(model.maxOutputTokens === undefined ? {} : { maxOutputTokens: model.maxOutputTokens }),
106
110
  ...(model.reasoningEffort === undefined ? {} : { reasoningEffort: model.reasoningEffort }),
@@ -113,10 +117,19 @@ export function mergePulseConfigs(layers) {
113
117
  let merged = {};
114
118
  for (const layer of layers) {
115
119
  const value = layer.trust === 'workspace' ? sanitizeWorkspaceConfig(layer.value) : layer.value;
120
+ const providers = { ...(merged.providers ?? {}) };
121
+ for (const [providerId, profile] of Object.entries(value.providers ?? {})) {
122
+ const previous = providers[providerId];
123
+ providers[providerId] = {
124
+ ...previous,
125
+ ...profile,
126
+ ...(previous?.models === undefined && profile.models === undefined ? {} : { models: { ...previous?.models, ...profile.models } }),
127
+ };
128
+ }
116
129
  merged = {
117
130
  ...merged,
118
131
  ...value,
119
- ...(merged.providers === undefined && value.providers === undefined ? {} : { providers: { ...merged.providers, ...value.providers } }),
132
+ ...(merged.providers === undefined && value.providers === undefined ? {} : { providers }),
120
133
  ...(merged.models === undefined && value.models === undefined ? {} : { models: { ...merged.models, ...value.models } }),
121
134
  };
122
135
  }
@@ -1,5 +1,23 @@
1
1
  // Generated from CHANGELOG.md and CHANGELOG.en.md by scripts/release/sync-cli-highlights.mjs. Do not edit manually.
2
2
  export const releaseHighlights = {
3
+ "0.5.1": [
4
+ {
5
+ "zh": "模型目录按供应商管理,支持不同供应商登记同名模型;默认模型、助理聊天、任务路由、即时预回复和审批复核统一选择已登记模型。",
6
+ "en": "Models are managed under providers, allowing different providers to register the same model name; defaults, assistant chat, task routing, instant replies, and approval review all select registered models."
7
+ },
8
+ {
9
+ "zh": "项目侧栏支持组织会话与项目,并完善工作区信任边界及模型配置迁移。",
10
+ "en": "Added project sidebar organization and improved workspace trust boundaries and model configuration migration."
11
+ },
12
+ {
13
+ "zh": "即时预回复按所选供应商模型读取协议与连接配置;审批复核继续限制为本机 Ollama,并在异常时交回人工。",
14
+ "en": "Instant replies use the selected provider's protocol and connection settings; approval review remains restricted to local Ollama and falls back to human review on errors."
15
+ },
16
+ {
17
+ "zh": "改善 Jarvis 经验记录、上下文筛选和持久化交付流程。",
18
+ "en": "Improved Jarvis experience recording, context filtering, and durable delivery workflows."
19
+ }
20
+ ],
3
21
  "0.5.0": [
4
22
  {
5
23
  "zh": "新增独立 Web 工作台,统一助理聊天、项目工作会话、历史记录、设置与本地终端。",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A local-first AI task assistant for coding, research, and file workflows.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "keywords": [
@@ -36,7 +36,7 @@
36
36
  "registry": "https://registry.npmjs.org"
37
37
  },
38
38
  "dependencies": {
39
- "@hunterzhu/pulse-server": "0.5.0",
39
+ "@hunterzhu/pulse-server": "0.5.1",
40
40
  "chalk": "^5.4.1",
41
41
  "highlight.js": "^11.10.0",
42
42
  "ink": "^7.1.1",