@hunterzhu/pulse-cli 0.4.4 → 0.4.6

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
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import { isAbsolute, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
6
+ import { assertJarvisApiUrl, createMcpCapabilityPack, createPdfCapabilityPack, createSkillCapabilityPack, createSpreadsheetCapabilityPack } from '@hunterzhu/pulse-server';
7
7
  import { defaultPulseConfig, ensurePulseUserConfig, expandHome, loadPulseConfig } from './config.js';
8
8
  import { runInteractive } from './commands/interactive.js';
9
9
  import { runOneShot } from './commands/run.js';
@@ -66,12 +66,36 @@ Options:
66
66
  --mock-after-tool-response <text> deterministic response after tool execution
67
67
  --task-controller force the multi-stage task controller
68
68
  --no-task-controller keep a single ReAct loop
69
+ --jarvis enable Jarvis cognitive memory architecture
70
+ --no-jarvis disable Jarvis cognitive memory architecture
71
+ --jarvis-url <url> Jarvis API base URL (default http://127.0.0.1:7330)
72
+ --jarvis-token <token> bearer token for the Jarvis API
73
+ --jarvis-budget <n> context token budget sent to Jarvis (default 4000)
74
+ --jarvis-allow-remote allow a Jarvis URL that is not loopback
69
75
  --live doctor: make one real provider request
70
76
  --no-color disable terminal styling
71
77
  --help, -h show this help
72
78
  --version, -v show the version
73
79
  setup --force write a user config template (also created on first run)
74
80
  `;
81
+ const knownBooleanFlags = new Set([
82
+ 'resume',
83
+ 'read-only',
84
+ 'auto-approve',
85
+ 'allow-network',
86
+ 'no-network',
87
+ 'trust-workspace',
88
+ 'task-controller',
89
+ 'no-task-controller',
90
+ 'live',
91
+ 'no-color',
92
+ 'help',
93
+ 'version',
94
+ 'force',
95
+ 'jarvis',
96
+ 'no-jarvis',
97
+ 'jarvis-allow-remote',
98
+ ]);
75
99
  export function parse(argv) {
76
100
  const options = {};
77
101
  const positionals = [];
@@ -95,6 +119,10 @@ export function parse(argv) {
95
119
  options[key] = inline;
96
120
  continue;
97
121
  }
122
+ if (knownBooleanFlags.has(key)) {
123
+ options[key] = true;
124
+ continue;
125
+ }
98
126
  const next = forwarded[index + 1];
99
127
  if (next && !next.startsWith('-')) {
100
128
  options[key] = next;
@@ -357,6 +385,32 @@ export async function hostOptions(parsed) {
357
385
  }
358
386
  }
359
387
  const explicitTaskController = parsed.options['task-controller'] === true ? true : parsed.options['no-task-controller'] === true ? false : mockParallelPlanOption !== undefined ? true : undefined;
388
+ const explicitJarvis = parsed.options['jarvis'] === true
389
+ ? true
390
+ : parsed.options['no-jarvis'] === true
391
+ ? false
392
+ : process.env.PULSE_JARVIS_ENABLED !== undefined
393
+ ? process.env.PULSE_JARVIS_ENABLED === '1' || process.env.PULSE_JARVIS_ENABLED.toLowerCase() === 'true'
394
+ : config.jarvis?.enabled;
395
+ const jarvisUrl = option(parsed.options, 'jarvis-url') ?? process.env.PULSE_JARVIS_API_URL ?? config.jarvis?.apiUrl;
396
+ const jarvisToken = option(parsed.options, 'jarvis-token') ?? process.env.PULSE_JARVIS_TOKEN ?? config.jarvis?.token;
397
+ const jarvisBudget = parsePositiveInteger(option(parsed.options, 'jarvis-budget') ?? process.env.PULSE_JARVIS_BUDGET) ?? config.jarvis?.contextTokenBudget;
398
+ const jarvisAutoCandidate = config.jarvis?.autoCandidate ?? true;
399
+ const jarvisAllowRemote = parsed.options['jarvis-allow-remote'] === true
400
+ || process.env.PULSE_JARVIS_ALLOW_REMOTE === '1'
401
+ || process.env.PULSE_JARVIS_ALLOW_REMOTE?.toLowerCase() === 'true'
402
+ || config.jarvis?.allowRemote === true;
403
+ const jarvisEnabled = explicitJarvis ?? false;
404
+ if (jarvisEnabled)
405
+ assertJarvisApiUrl(jarvisUrl ?? 'http://127.0.0.1:7330', jarvisAllowRemote);
406
+ const jarvis = {
407
+ enabled: jarvisEnabled,
408
+ ...(jarvisUrl ? { apiUrl: jarvisUrl } : {}),
409
+ ...(jarvisToken ? { token: jarvisToken } : {}),
410
+ ...(jarvisBudget ? { contextTokenBudget: jarvisBudget } : {}),
411
+ autoCandidate: jarvisAutoCandidate,
412
+ allowRemote: jarvisAllowRemote,
413
+ };
360
414
  return {
361
415
  ...(explicitTaskController === undefined ? {} : { taskController: explicitTaskController }),
362
416
  ...(cwd === undefined ? {} : { cwd }),
@@ -384,6 +438,7 @@ export async function hostOptions(parsed) {
384
438
  ...(configuredAutoCompactPercent === undefined ? {} : { autoCompactPercent: Math.min(90, configuredAutoCompactPercent) }),
385
439
  ...(allowNetwork === undefined ? {} : { allowNetwork }),
386
440
  ...(config.networkHosts === undefined ? {} : { networkHosts: config.networkHosts }),
441
+ jarvis,
387
442
  };
388
443
  }
389
444
  export async function main() {
@@ -1,7 +1,8 @@
1
1
  import { createLocalHost } from '@hunterzhu/pulse-server';
2
+ import { consumeToolEvent } from '../utils/tool-view.js';
2
3
  import { runInteractive } from './interactive.js';
3
4
  import { bindRunSignals } from './signals.js';
4
- function writeEvent(event, format) {
5
+ function writeEvent(event, format, printed) {
5
6
  if (format === 'jsonl') {
6
7
  process.stdout.write(`${JSON.stringify(event)}\n`);
7
8
  return;
@@ -19,6 +20,9 @@ function writeEvent(event, format) {
19
20
  else if (event.type === 'error') {
20
21
  process.stderr.write(`\n[错误] ${String(event.data ?? '')}\n`);
21
22
  }
23
+ const toolLines = consumeToolEvent(event, printed, process.stderr.columns || 80);
24
+ if (toolLines.length)
25
+ process.stderr.write(`${toolLines.join('\n')}\n`);
22
26
  }
23
27
  export async function runResume(options, conversationId, task, format = 'text', version = '0.4.1') {
24
28
  // 交互式终端进入 Ink 界面;jsonl 保持非交互输出
@@ -36,10 +40,11 @@ export async function runResume(options, conversationId, task, format = 'text',
36
40
  activeRun = run;
37
41
  let streamedText = false;
38
42
  let approvalCancelled = false;
43
+ const printedTools = new Set();
39
44
  for await (const event of run.events) {
40
45
  if (event.type === 'text')
41
46
  streamedText = true;
42
- writeEvent(event, format);
47
+ writeEvent(event, format, printedTools);
43
48
  if (event.type === 'waiting' && !approvalCancelled) {
44
49
  approvalCancelled = true;
45
50
  process.stderr.write('\n[错误] 此运行处于非交互模式,无法请求工具审批;运行已取消。\n');
@@ -1,6 +1,7 @@
1
1
  import { createLocalHost } from '@hunterzhu/pulse-server';
2
+ import { consumeToolEvent } from '../utils/tool-view.js';
2
3
  import { bindRunSignals } from './signals.js';
3
- function writeEvent(event, format) {
4
+ function writeEvent(event, format, printed) {
4
5
  if (format === 'jsonl') {
5
6
  process.stdout.write(`${JSON.stringify(event)}\n`);
6
7
  return;
@@ -18,6 +19,9 @@ function writeEvent(event, format) {
18
19
  else if (event.type === 'error') {
19
20
  process.stderr.write(`\n[错误] ${String(event.data ?? '')}\n`);
20
21
  }
22
+ const toolLines = consumeToolEvent(event, printed, process.stderr.columns || 80);
23
+ if (toolLines.length)
24
+ process.stderr.write(`${toolLines.join('\n')}\n`);
21
25
  }
22
26
  export async function runOneShot(options, task, format = 'text', version = '0.4.1') {
23
27
  const host = createLocalHost(options);
@@ -33,10 +37,11 @@ export async function runOneShot(options, task, format = 'text', version = '0.4.
33
37
  activeRun = run;
34
38
  let streamedText = false;
35
39
  let approvalCancelled = false;
40
+ const printedTools = new Set();
36
41
  for await (const event of run.events) {
37
42
  if (event.type === 'text')
38
43
  streamedText = true;
39
- writeEvent(event, format);
44
+ writeEvent(event, format, printedTools);
40
45
  if (event.type === 'waiting' && !approvalCancelled) {
41
46
  approvalCancelled = true;
42
47
  process.stderr.write('\n[错误] 此运行处于非交互模式,无法请求工具审批;运行已取消。\n');
@@ -7,7 +7,7 @@ export function ApprovalPrompt({ request, isFocused = false, inputMode = false,
7
7
  const tools = request.tools ?? [{ name: request.toolName, input: request.toolArgs }];
8
8
  const previews = tools.map((tool) => describeApprovalTool(tool));
9
9
  const truncated = previews.some((preview) => preview.truncated);
10
- return (_jsxs(Box, { borderStyle: "double", borderColor: theme.warning, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 \u5BA1\u6279\u8BF7\u6C42" }), _jsx(Text, { color: theme.tool, children: request.toolName })] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: previews.map((preview, index) => (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.tool, bold: true, children: preview.name }), _jsx(Text, { color: theme.dim, children: preview.body })] }, `${preview.name}-${index}`))) }), truncated && (_jsx(Text, { color: theme.warning, children: "\u9884\u89C8\u5DF2\u622A\u65AD\u3002\u9009\u62E9\u201C\u540C\u610F\u201D\u4F1A\u6267\u884C\u4E0A\u9762\u5217\u51FA\u7684\u5B8C\u6574\u64CD\u4F5C\uFF0C\u800C\u4E0D\u53EA\u662F\u53EF\u89C1\u7247\u6BB5\u3002" })), request.prompt && (_jsx(Box, { children: _jsx(Text, { children: request.prompt }) })), request.digest && _jsxs(Text, { color: theme.dim, children: ["\u6458\u8981\u6821\u9A8C\uFF1A", request.digest] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: isFocused
10
+ return (_jsxs(Box, { borderStyle: "double", borderColor: theme.warning, flexDirection: "column", paddingX: 1, marginTop: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(Text, { color: theme.warning, bold: true, children: "\u26A0 \u5BA1\u6279\u8BF7\u6C42" }), _jsx(Text, { color: theme.tool, children: request.toolName })] }), _jsx(Box, { flexDirection: "column", marginBottom: 1, children: previews.map((preview, index) => (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsx(Text, { color: theme.tool, bold: true, children: preview.name }), _jsx(Text, { children: preview.body })] }, `${preview.name}-${index}`))) }), truncated && (_jsx(Text, { color: theme.warning, children: "\u9884\u89C8\u5DF2\u622A\u65AD\u3002\u9009\u62E9\u201C\u540C\u610F\u201D\u4F1A\u6267\u884C\u4E0A\u9762\u5217\u51FA\u7684\u5B8C\u6574\u64CD\u4F5C\uFF0C\u800C\u4E0D\u53EA\u662F\u53EF\u89C1\u7247\u6BB5\u3002" })), request.prompt && (_jsx(Box, { children: _jsx(Text, { children: request.prompt }) })), request.digest && _jsxs(Text, { color: theme.dim, children: ["\u6458\u8981\u6821\u9A8C\uFF1A", request.digest] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, children: isFocused
11
11
  ? '审批选择已聚焦(↑↓ 选择,Enter 确认;Tab 切换到输入;默认拒绝)'
12
12
  : inputMode
13
13
  ? '输入模式:可发送补充信息;按 Tab 返回审批选择'
package/dist/config.d.ts CHANGED
@@ -77,6 +77,21 @@ export interface PulseCliConfig {
77
77
  /** Explicit network destinations; workspace configuration cannot grant these. */
78
78
  networkHosts?: string[];
79
79
  allowNetwork?: boolean;
80
+ jarvis?: PulseCliJarvisConfig;
81
+ }
82
+ export interface PulseCliJarvisConfig {
83
+ /** Master switch to enable or disable Jarvis cognitive memory architecture. */
84
+ enabled?: boolean;
85
+ /** Base URL of the running Jarvis server. Defaults to http://127.0.0.1:7330 */
86
+ apiUrl?: string;
87
+ /** Optional bearer token if Jarvis requires auth. */
88
+ token?: string;
89
+ /** Max token budget for dynamic context package built by Jarvis. Defaults to 4000. */
90
+ contextTokenBudget?: number;
91
+ /** Whether to automatically record candidate memories upon task completion. Defaults to true. */
92
+ autoCandidate?: boolean;
93
+ /** Allow an API URL that is not loopback. Defaults to false. */
94
+ allowRemote?: boolean;
80
95
  }
81
96
  export declare const defaultPulseConfig: PulseCliConfig;
82
97
  export declare function defaultPulseConfigPath(): string;
@@ -84,7 +99,7 @@ export declare function defaultPulseHomePath(): string;
84
99
  /** Create the user config on first run without replacing an existing file. */
85
100
  export declare function ensurePulseUserConfig(path?: string): Promise<void>;
86
101
  export declare function expandHome(path: string | undefined): string | undefined;
87
- /** Workspace files must not escalate approval, network, provider credentials, or the system prompt. */
102
+ /** Workspace files must not escalate approval, network, provider credentials, the system prompt, or the Jarvis endpoint. */
88
103
  export declare function sanitizeWorkspaceConfig(value: PulseCliConfig): PulseCliConfig;
89
104
  export declare function mergePulseConfigs(layers: Array<{
90
105
  value: PulseCliConfig;
package/dist/config.js CHANGED
@@ -27,6 +27,13 @@ export const defaultPulseConfig = {
27
27
  maxTurns: 32,
28
28
  autoCompactPercent: 90,
29
29
  allowNetwork: false,
30
+ jarvis: {
31
+ enabled: false,
32
+ apiUrl: 'http://127.0.0.1:7330',
33
+ contextTokenBudget: 4_000,
34
+ autoCandidate: true,
35
+ allowRemote: false,
36
+ },
30
37
  };
31
38
  export function defaultPulseConfigPath() {
32
39
  return join(defaultPulseHomePath(), 'config.json');
@@ -69,7 +76,7 @@ function asConfig(value) {
69
76
  return undefined;
70
77
  return value;
71
78
  }
72
- /** Workspace files must not escalate approval, network, provider credentials, or the system prompt. */
79
+ /** Workspace files must not escalate approval, network, provider credentials, the system prompt, or the Jarvis endpoint. */
73
80
  export function sanitizeWorkspaceConfig(value) {
74
81
  const providers = value.providers === undefined ? undefined : Object.fromEntries(Object.entries(value.providers).flatMap(([name, profile]) => {
75
82
  const safe = {
@@ -1,5 +1,7 @@
1
1
  import type { LocalHost } from '@hunterzhu/pulse-server';
2
- import type { ApprovalRequest, AskRequest, DisplayMessage, LaneDisplay } from '../types.js';
2
+ import type { ApprovalRequest, AskRequest, DisplayMessage, LaneDisplay, ToolCallDisplay } from '../types.js';
3
+ /** Running snapshots must not erase a settled call or replace known arguments with an empty object. */
4
+ export declare function mergeToolCall(current: ToolCallDisplay | undefined, next: ToolCallDisplay, source: 'running' | 'settled'): ToolCallDisplay;
3
5
  export declare function describeFactStatus(data: unknown): string;
4
6
  export declare function useRun({ host, conversationId, addAssistantMessage, }: {
5
7
  host: LocalHost | null;
@@ -1,8 +1,20 @@
1
1
  import { useState, useRef, useCallback, useEffect } from 'react';
2
- function toolStatus(value) {
3
- if (value === 'succeeded' || value === 'failed' || value === 'running' || value === 'cancelled')
4
- return value;
5
- return 'running';
2
+ import { runningToolCalls, toolCallFromObservation } from '../utils/tool-view.js';
3
+ /** Running snapshots must not erase a settled call or replace known arguments with an empty object. */
4
+ export function mergeToolCall(current, next, source) {
5
+ if (!current)
6
+ return next;
7
+ if (source === 'running' && current.status !== 'running')
8
+ return current;
9
+ const hasNextArgs = next.arguments !== undefined && Object.keys(next.arguments).length > 0;
10
+ return {
11
+ ...current,
12
+ ...next,
13
+ arguments: hasNextArgs ? next.arguments : current.arguments,
14
+ ...(next.preview === undefined && current.preview !== undefined ? { preview: current.preview } : {}),
15
+ ...(next.result === undefined && current.result !== undefined ? { result: current.result } : {}),
16
+ status: source === 'running' ? 'running' : next.status,
17
+ };
6
18
  }
7
19
  export function describeFactStatus(data) {
8
20
  if (typeof data === 'string') {
@@ -146,26 +158,17 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
146
158
  addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls?.map((call) => ({ ...call })) });
147
159
  break;
148
160
  case 'observation': {
149
- const obs = event.data;
150
- if (obs && typeof obs === 'object' && typeof obs.tool === 'string') {
151
- const id = String(obs.toolCallId ?? obs.tool);
152
- const nextCall = {
153
- id,
154
- name: obs.tool,
155
- arguments: obs.args && typeof obs.args === 'object' && !Array.isArray(obs.args)
156
- ? obs.args
157
- : {},
158
- status: toolStatus(obs.status),
159
- ...(obs.result === undefined ? {} : { result: obs.result }),
160
- };
161
+ const nextCall = toolCallFromObservation(event.data);
162
+ if (nextCall) {
161
163
  const calls = assistantMessage.toolCalls ?? [];
162
- const index = calls.findIndex((call) => call.id === id);
164
+ const index = calls.findIndex((call) => call.id === nextCall.id);
165
+ const merged = mergeToolCall(index >= 0 ? calls[index] : undefined, nextCall, 'settled');
163
166
  assistantMessage.toolCalls = index >= 0
164
- ? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
165
- : [...calls, nextCall];
167
+ ? calls.map((call, callIndex) => callIndex === index ? merged : call)
168
+ : [...calls, merged];
166
169
  addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
167
- setCurrentStep(nextCall.status === 'succeeded' ? `已完成 ${nextCall.name},正在整理结果…` : nextCall.status === 'failed' ? `${nextCall.name} 未成功,正在处理错误…` : `正在执行 ${nextCall.name}…`);
168
- addProgress(`tool-result:${id}:${nextCall.status}`, `${nextCall.status === 'succeeded' ? '已完成' : nextCall.status === 'cancelled' ? '已取消' : '执行失败'} · ${nextCall.name}`);
170
+ setCurrentStep(merged.status === 'succeeded' ? `已完成 ${merged.name},正在整理结果…` : merged.status === 'failed' ? `${merged.name} 未成功,正在处理错误…` : `正在执行 ${merged.name}…`);
171
+ addProgress(`tool-result:${merged.id}:${merged.status}`, `${merged.status === 'succeeded' ? '已完成' : merged.status === 'cancelled' ? '已取消' : '执行失败'} · ${merged.name}`);
169
172
  }
170
173
  break;
171
174
  }
@@ -247,26 +250,30 @@ export function useRun({ host, conversationId, addAssistantMessage, }) {
247
250
  }));
248
251
  const activeLanes = fact.lanes.filter((lane) => typeof lane.status === 'string' && !['succeeded', 'failed', 'cancelled'].includes(lane.status));
249
252
  const active = activeLanes.find((lane) => lane.activityKind === 'tool') ?? activeLanes[0];
253
+ const running = runningToolCalls(fact);
254
+ if (running.length) {
255
+ let calls = assistantMessage.toolCalls ?? [];
256
+ for (const nextCall of running) {
257
+ const index = calls.findIndex((call) => call.id === nextCall.id);
258
+ const merged = mergeToolCall(index >= 0 ? calls[index] : undefined, nextCall, 'running');
259
+ calls = index >= 0
260
+ ? calls.map((call, callIndex) => callIndex === index ? merged : call)
261
+ : [...calls, merged];
262
+ }
263
+ assistantMessage.toolCalls = calls;
264
+ addAssistantMessage({ ...assistantMessage, toolCalls: calls.map((call) => ({ ...call })) });
265
+ }
250
266
  if (active && typeof active.goal === 'string') {
251
- const toolName = active.activityKind === 'tool' && typeof active.activity === 'string' ? active.activity : undefined;
267
+ const latest = running.at(-1);
268
+ const toolName = latest?.name ?? (active.activityKind === 'tool' && typeof active.activity === 'string' ? active.activity : undefined);
252
269
  const activity = toolName ? `正在调用工具 · ${toolName}` : typeof active.activity === 'string'
253
270
  ? active.activity === 'llm' ? '正在分析需求和已有证据…' : '正在处理任务…'
254
271
  : '正在处理任务…';
255
272
  setCurrentStep(`${activity}:${active.goal}`);
256
273
  const activityId = toolName
257
- ? (typeof active.activityEffectId === 'string' ? active.activityEffectId : active.id)
274
+ ? (typeof active.activityEffectId === 'string' ? active.activityEffectId : latest?.id ?? active.id)
258
275
  : active.activity === 'llm' ? 'analysis' : `${active.id}:${active.status}`;
259
276
  addProgress(`activity:${activityId}`, activity);
260
- if (toolName && typeof active.activityToolCallId === 'string') {
261
- const id = active.activityToolCallId;
262
- const calls = assistantMessage.toolCalls ?? [];
263
- const index = calls.findIndex((call) => call.id === id);
264
- const nextCall = { id, name: toolName, status: 'running', arguments: {} };
265
- assistantMessage.toolCalls = index >= 0
266
- ? calls.map((call, callIndex) => callIndex === index ? { ...call, ...nextCall } : call)
267
- : [...calls, nextCall];
268
- addAssistantMessage({ ...assistantMessage, toolCalls: assistantMessage.toolCalls.map((call) => ({ ...call })) });
269
- }
270
277
  }
271
278
  break;
272
279
  }
@@ -1,5 +1,53 @@
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.4.6": [
4
+ {
5
+ "zh": "支持通过 CLI、环境变量或用户配置启用 Jarvis 会话、项目上下文、执行事件和候选记忆;默认关闭,服务不可用时继续独立运行。",
6
+ "en": "Jarvis sessions, project context, execution events, and candidate memories can be enabled through CLI flags, environment variables, or user configuration; integration is off by default and execution continues independently if the service is unavailable."
7
+ },
8
+ {
9
+ "zh": "工具事件仅发送执行标识、工具名称和状态,避免参数、文件内容和输出中的凭据进入记忆;发送的文本增加常见凭据脱敏。",
10
+ "en": "Tool events send only execution identifiers, tool names, and statuses to keep credentials in arguments, file contents, and outputs out of memory; outbound text redacts common credential forms."
11
+ },
12
+ {
13
+ "zh": "持久化每次运行的 Jarvis 会话、上下文和已记录事件,恢复运行时保留上下文并避免重放已记录事件。",
14
+ "en": "Persist each run's Jarvis session, context, and recorded events so restored runs retain context without replaying recorded events."
15
+ },
16
+ {
17
+ "zh": "事件记录不再依赖界面消费,结束前等待本次运行的写入完成;并行运行不会互相等待记忆写入。",
18
+ "en": "Event recording no longer depends on UI consumption, and completion waits for the current run's writes; concurrent runs do not wait for each other's memory writes."
19
+ },
20
+ {
21
+ "zh": "候选记忆标题统一为单行并限制长度;仅验收通过的任务可记录为成功,工作区配置不能启用 Jarvis 或覆盖其端点。",
22
+ "en": "Candidate memory titles are normalized to a single line and capped in length; only accepted tasks are recorded as successful, and workspace configuration cannot enable Jarvis or override its endpoint."
23
+ }
24
+ ],
25
+ "0.4.5": [
26
+ {
27
+ "zh": "复杂任务不再受全局模型调用次数上限截断,继续通过阶段、运行时限和循环检测控制执行;旧会话保持原有预算语义。",
28
+ "en": "Complex tasks are no longer cut off by a run-wide model-call ceiling; stage limits, runtime limits, and loop detection still bound execution, while older sessions retain their original budget semantics."
29
+ },
30
+ {
31
+ "zh": "大任务上下文从当前目标和阶段出发,沿依赖、证据和文件路径按需展开,同时保留用户要求及最近对话。",
32
+ "en": "Large-task context expands on demand from the current objective and stage along dependencies, evidence, and file paths, while preserving user requirements and the latest exchange."
33
+ },
34
+ {
35
+ "zh": "CLI 展示每次工具调用的状态、文件修改 diff 和命令或读取预览,便于查看并行执行与审批内容。",
36
+ "en": "The CLI displays each tool call's status, file-edit diff, and command or read preview to make parallel execution and approval contents easier to inspect."
37
+ },
38
+ {
39
+ "zh": "上下文展开支持继续访问此前已到达的节点,并在恢复会话后保留展开范围;剩余消息和目标中的文件路径可以正确检索。",
40
+ "en": "Context expansion can continue from previously reached nodes after session restoration; remaining messages and file paths in the objective can be retrieved correctly."
41
+ },
42
+ {
43
+ "zh": "最终验收引用无效证据时,将具体错误反馈给重试,避免重复提交相同结果。",
44
+ "en": "Final acceptance feeds invalid evidence-reference errors back into retries to avoid repeatedly submitting the same result."
45
+ },
46
+ {
47
+ "zh": "暂存写入和追加内容可在审批中预览;长 diff 保留尾部修改,隐藏内容和空行正确标记为截断。",
48
+ "en": "Staged writes and appends include approval previews; long diffs retain tail edits, and hidden content and blank lines are correctly marked as truncated."
49
+ }
50
+ ],
3
51
  "0.4.4": [
4
52
  {
5
53
  "zh": "多阶段任务获得完整工具定义,并保留重试和依赖阶段的验收证据;补验证或补报告不再强制重复修改文件。",
package/dist/types.d.ts CHANGED
@@ -26,12 +26,24 @@ export interface DisplayMessage {
26
26
  /** Token 统计 */
27
27
  tokenStats?: TokenStatsData | undefined;
28
28
  }
29
+ /** Bounded settlement summary. File bodies stay out of this object. */
30
+ export interface ToolCallPreview {
31
+ bytes?: number | undefined;
32
+ lines?: number | undefined;
33
+ truncated?: boolean | undefined;
34
+ matched?: number | undefined;
35
+ locations?: string[] | undefined;
36
+ exitCode?: number | null | undefined;
37
+ output?: string | undefined;
38
+ replacements?: number | undefined;
39
+ }
29
40
  /** 工具调用展示数据 */
30
41
  export interface ToolCallDisplay {
31
42
  id: string;
32
43
  name: string;
33
44
  arguments?: Record<string, unknown> | undefined;
34
45
  result?: unknown;
46
+ preview?: ToolCallPreview | undefined;
35
47
  status: 'running' | 'succeeded' | 'failed' | 'cancelled';
36
48
  durationMs?: number | undefined;
37
49
  }
@@ -1,50 +1,14 @@
1
- import { stripTerminalControls } from './ansi.js';
2
- const previewLimit = 1_000;
3
- function clip(value) {
4
- const clean = stripTerminalControls(value);
5
- if (clean.length <= previewLimit)
6
- return { text: clean, truncated: false };
7
- const hidden = clean.length - previewLimit;
8
- return {
9
- truncated: true,
10
- text: `${clean.slice(0, previewLimit)}\n… 还有 ${hidden} 个字符未显示。批准会执行完整内容(共 ${clean.length} 个字符)。`,
11
- };
12
- }
13
- function textField(input, key) {
14
- const value = input[key];
15
- return typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value);
16
- }
1
+ import { renderToolCalls } from './tool-view.js';
17
2
  export function describeApprovalTool(tool) {
18
- if (tool.name === 'fs.write') {
19
- const content = clip(textField(tool.input, 'content'));
20
- const path = stripTerminalControls(textField(tool.input, 'path'));
21
- return {
22
- name: tool.name,
23
- truncated: content.truncated,
24
- body: `路径: ${path}\n内容(${textField(tool.input, 'content').length} 个字符):\n${content.text}`,
25
- };
26
- }
27
- if (tool.name === 'fs.apply_patch') {
28
- const find = clip(textField(tool.input, 'find'));
29
- const replace = clip(textField(tool.input, 'replace'));
30
- const path = stripTerminalControls(textField(tool.input, 'path'));
31
- const all = tool.input.all === true ? '全部匹配' : '第一处匹配';
32
- return {
33
- name: tool.name,
34
- truncated: find.truncated || replace.truncated,
35
- body: `路径: ${path}\n范围: ${all}\n- ${find.text}\n+ ${replace.text}`,
36
- };
37
- }
38
- if (tool.name === 'shell.exec') {
39
- const command = stripTerminalControls(textField(tool.input, 'command'));
40
- const args = Array.isArray(tool.input.args) ? tool.input.args.map((arg) => stripTerminalControls(String(arg))) : [];
41
- const cwd = stripTerminalControls(textField(tool.input, 'cwd') || '.');
42
- return {
3
+ const view = renderToolCalls([{
4
+ id: 'approval',
43
5
  name: tool.name,
44
- truncated: false,
45
- body: `命令: ${command}\n参数: ${args.join(' ') || '(无)'}\n目录: ${cwd}`,
46
- };
47
- }
48
- const serialized = clip(JSON.stringify(tool.input, null, 2));
49
- return { name: tool.name, truncated: serialized.truncated, body: serialized.text };
6
+ arguments: tool.input,
7
+ status: 'running',
8
+ }], { width: 100 });
9
+ const hidden = Math.max(0, view.totalChars - view.shownChars);
10
+ const notice = view.truncated
11
+ ? `\n… 还有 ${hidden} 个字符未显示。批准会执行完整内容(共 ${view.totalChars} 个字符)。`
12
+ : '';
13
+ return { name: tool.name, truncated: view.truncated, body: `${view.lines.join('\n')}${notice}` };
50
14
  }
@@ -0,0 +1,17 @@
1
+ import type { ToolCallDisplay, Verbosity } from '../types.js';
2
+ export interface ToolView {
3
+ lines: string[];
4
+ truncated: boolean;
5
+ totalChars: number;
6
+ shownChars: number;
7
+ }
8
+ export declare function renderToolCalls(calls: ToolCallDisplay[], options?: {
9
+ verbosity?: Verbosity;
10
+ width?: number;
11
+ }): ToolView;
12
+ export declare function toolCallFromObservation(data: unknown): ToolCallDisplay | undefined;
13
+ export declare function runningToolCalls(data: unknown): ToolCallDisplay[];
14
+ export declare function consumeToolEvent(event: {
15
+ type: string;
16
+ data?: unknown;
17
+ }, printed: Set<string>, width: number): string[];
@@ -0,0 +1,471 @@
1
+ import chalk from 'chalk';
2
+ import wrapAnsi from 'wrap-ansi';
3
+ import { theme } from '../theme.js';
4
+ import { stripAnsi, stripTerminalControls } from './ansi.js';
5
+ const verbs = {
6
+ 'fs.read': '读取',
7
+ 'fs.search': '搜索',
8
+ 'fs.list': '列出',
9
+ 'fs.write': '新建',
10
+ 'fs.apply_patch': '编辑',
11
+ 'fs.apply_patches': '编辑',
12
+ 'fs.stage': '写入',
13
+ 'fs.move': '移动',
14
+ 'shell.exec': '运行',
15
+ 'web.fetch': '获取',
16
+ 'web.search': '检索',
17
+ };
18
+ const lineCap = 24;
19
+ const lineWidth = 160;
20
+ const contextLines = 2;
21
+ export function renderToolCalls(calls, options = {}) {
22
+ const width = Math.max(8, options.width ?? 80);
23
+ const views = calls.map((call) => renderToolCall(call, { ...options, width }));
24
+ return {
25
+ lines: views.flatMap((view) => view.lines),
26
+ truncated: views.some((view) => view.truncated),
27
+ totalChars: views.reduce((sum, view) => sum + view.totalChars, 0),
28
+ shownChars: views.reduce((sum, view) => sum + view.shownChars, 0),
29
+ };
30
+ }
31
+ export function toolCallFromObservation(data) {
32
+ if (!data || typeof data !== 'object' || Array.isArray(data))
33
+ return undefined;
34
+ const obs = data;
35
+ if (typeof obs.tool !== 'string')
36
+ return undefined;
37
+ const preview = parsePreview(obs.preview);
38
+ return {
39
+ id: String(obs.toolCallId ?? obs.tool),
40
+ name: obs.tool,
41
+ arguments: record(obs.args),
42
+ status: toolStatus(obs.status),
43
+ ...(obs.result === undefined ? {} : { result: obs.result }),
44
+ ...(preview === undefined ? {} : { preview }),
45
+ };
46
+ }
47
+ export function runningToolCalls(data) {
48
+ if (!data || typeof data !== 'object' || Array.isArray(data))
49
+ return [];
50
+ const fact = data;
51
+ if (fact.type !== 'lane.snapshot' || !Array.isArray(fact.lanes))
52
+ return [];
53
+ const calls = [];
54
+ for (const lane of fact.lanes) {
55
+ if (!lane || typeof lane !== 'object' || Array.isArray(lane))
56
+ continue;
57
+ const item = lane;
58
+ const activities = Array.isArray(item.activities) ? item.activities : [item];
59
+ for (const activity of activities) {
60
+ if (!activity || typeof activity !== 'object' || Array.isArray(activity))
61
+ continue;
62
+ const row = activity;
63
+ if (row.activityKind !== 'tool' || typeof row.activity !== 'string' || typeof row.activityToolCallId !== 'string')
64
+ continue;
65
+ calls.push({
66
+ id: row.activityToolCallId,
67
+ name: row.activity,
68
+ status: 'running',
69
+ arguments: record(row.activityArguments),
70
+ });
71
+ }
72
+ }
73
+ return calls;
74
+ }
75
+ export function consumeToolEvent(event, printed, width) {
76
+ const calls = event.type === 'observation'
77
+ ? [toolCallFromObservation(event.data)].filter((call) => call !== undefined)
78
+ : event.type === 'fact'
79
+ ? runningToolCalls(event.data)
80
+ : [];
81
+ const lines = [];
82
+ for (const call of calls) {
83
+ const key = `${call.id}\0${call.status}\0${JSON.stringify(call.arguments ?? {})}\0${call.preview?.output ?? ''}`;
84
+ if (printed.has(key))
85
+ continue;
86
+ printed.add(key);
87
+ lines.push(...renderToolCalls([call], { width }).lines);
88
+ }
89
+ return lines;
90
+ }
91
+ function renderToolCall(call, options) {
92
+ const args = call.arguments ?? {};
93
+ const source = editableText(args);
94
+ const width = options.width;
95
+ const header = fit(headerLine(call), width);
96
+ const detail = detailLines(call, width);
97
+ const extra = options.verbosity === 'verbose' && call.result !== undefined ? resultLines(call.result, width) : [];
98
+ const lines = [...header, ...detail.lines, ...extra];
99
+ const truncated = detail.truncated;
100
+ return {
101
+ lines,
102
+ truncated: detail.shownChars < source.length || truncated,
103
+ totalChars: source.length,
104
+ shownChars: detail.shownChars,
105
+ };
106
+ }
107
+ function headerLine(call) {
108
+ const glyph = call.status === 'running'
109
+ ? chalk.yellow('◐')
110
+ : call.status === 'failed'
111
+ ? chalk.red('✗')
112
+ : call.status === 'cancelled'
113
+ ? chalk.dim('⊘')
114
+ : chalk.green('●');
115
+ const verb = chalk.hex(theme.tool)(verbs[call.name] ?? call.name);
116
+ const target = targetText(call);
117
+ const stat = diffStat(call);
118
+ return `${glyph} ${verb}${target ? ` ${chalk.dim(target)}` : ''}${stat ? ` ${stat}` : ''}`;
119
+ }
120
+ function targetText(call) {
121
+ const args = call.arguments ?? {};
122
+ if (call.name === 'shell.exec')
123
+ return shellCommand(args);
124
+ if (call.name === 'fs.move')
125
+ return joinPair(text(args, 'source'), text(args, 'destination'), ' → ');
126
+ if (call.name === 'fs.stage')
127
+ return stageTarget(args);
128
+ if (call.name === 'web.fetch')
129
+ return text(args, 'url');
130
+ if (call.name === 'web.search' || call.name === 'fs.search')
131
+ return text(args, 'query');
132
+ return text(args, 'path');
133
+ }
134
+ function detailLines(call, width) {
135
+ const args = call.arguments ?? {};
136
+ const failure = failureText(call);
137
+ const failed = failure ? fit(` └ ${failure}`, width) : [];
138
+ if (call.name === 'fs.apply_patch' || call.name === 'fs.apply_patches' || isContentWrite(call)) {
139
+ const diff = editLines(call, width);
140
+ return { lines: [...diff.lines, ...failed], shownChars: diff.shownChars, truncated: diff.truncated };
141
+ }
142
+ const lines = [];
143
+ if (call.name === 'shell.exec') {
144
+ const cwd = text(args, 'cwd');
145
+ if (cwd && cwd !== '.')
146
+ lines.push(...fit(` └ 目录 ${cwd}`, width));
147
+ const output = call.preview?.output ? stripTerminalControls(call.preview.output) : '';
148
+ if (output)
149
+ lines.push(...prefixBlock(output, width));
150
+ if (failed.length)
151
+ lines.push(...failed);
152
+ else if (call.status === 'succeeded' && !output)
153
+ lines.push(...fit(' └ 已完成', width));
154
+ if (call.preview?.truncated)
155
+ lines.push(...fit(chalk.dim(' └ 输出已截断'), width));
156
+ return { lines, shownChars: editableText(args).length, truncated: false };
157
+ }
158
+ if (failed.length)
159
+ return { lines: failed, shownChars: editableText(args).length, truncated: false };
160
+ const summary = summaryLine(call);
161
+ if (summary)
162
+ lines.push(...fit(` └ ${summary}`, width));
163
+ for (const location of call.preview?.locations ?? [])
164
+ lines.push(...fit(chalk.dim(` ${stripTerminalControls(location)}`), width));
165
+ if (!lines.length && call.name !== 'fs.read' && call.name !== 'fs.search' && call.name !== 'fs.list' && call.name !== 'fs.stage' && call.name !== 'fs.move' && call.name !== 'web.fetch' && call.name !== 'web.search') {
166
+ const dumped = dumpArgs(args);
167
+ if (dumped.clipped)
168
+ return { lines: fit(chalk.dim(dumped.text), width), shownChars: 0, truncated: true };
169
+ if (dumped.text && dumped.text !== '{}')
170
+ return { lines: fit(chalk.dim(dumped.text), width), shownChars: editableText(args).length, truncated: false };
171
+ }
172
+ return { lines, shownChars: editableText(args).length, truncated: false };
173
+ }
174
+ function isContentWrite(call) {
175
+ return call.name === 'fs.write' || call.name === 'fs.stage' && typeof call.arguments?.content === 'string';
176
+ }
177
+ function editLines(call, width) {
178
+ const args = call.arguments ?? {};
179
+ const hunks = isContentWrite(call)
180
+ ? [{ find: '', replace: text(args, 'content') }]
181
+ : call.name === 'fs.apply_patches'
182
+ ? patches(args)
183
+ : [{ find: text(args, 'find'), replace: text(args, 'replace') }];
184
+ const source = editableText(args);
185
+ const rendered = [];
186
+ let hidden = 0;
187
+ let omittedChars = 0;
188
+ if (args.all === true)
189
+ rendered.push(...fit(chalk.dim(' └ 全部匹配'), width));
190
+ for (const [index, hunk] of hunks.entries()) {
191
+ if (index > 0)
192
+ rendered.push(chalk.dim(' @@'));
193
+ const ops = hunk.find === '' && isContentWrite(call)
194
+ ? hunk.replace.split('\n').map((line, lineIndex) => ({ op: '+', text: line, line: lineIndex + 1 }))
195
+ : diffLines(hunk.find, hunk.replace);
196
+ const window = windowOps(ops);
197
+ hidden += window.hidden;
198
+ for (const op of ops)
199
+ if (!window.shown.includes(op))
200
+ omittedChars += op.text.length + 1;
201
+ for (const op of window.shown) {
202
+ const visible = clipLine(stripTerminalControls(op.text));
203
+ if (visible.clipped)
204
+ omittedChars += Math.max(0, stripTerminalControls(op.text).length - lineWidth);
205
+ const sign = op.op === ' ' ? ' ' : op.op;
206
+ const body = `${String(op.line).padStart(4)} │${sign} ${visible.text}`;
207
+ const colored = op.op === '+' ? chalk.green(body) : op.op === '-' ? chalk.red(body) : chalk.dim(body);
208
+ rendered.push(...fit(` ${colored}`, width));
209
+ }
210
+ }
211
+ if (hidden > 0)
212
+ rendered.push(...fit(chalk.dim(` … 还有 ${hidden} 行`), width));
213
+ return { lines: rendered, shownChars: Math.max(0, source.length - omittedChars), truncated: hidden > 0 || omittedChars > 0 };
214
+ }
215
+ function summaryLine(call) {
216
+ const preview = call.preview;
217
+ if (!preview)
218
+ return call.status === 'succeeded' && call.name === 'fs.stage' ? '已完成' : '';
219
+ if (call.name === 'fs.read' || call.name === 'web.fetch') {
220
+ const size = typeof preview.bytes === 'number' ? formatBytes(preview.bytes) : '';
221
+ const lines = typeof preview.lines === 'number' ? `${preview.lines} 行` : '';
222
+ return [lines, size, preview.truncated ? '未读完' : ''].filter(Boolean).join(' · ');
223
+ }
224
+ if (call.name === 'fs.search' || call.name === 'web.search')
225
+ return `${preview.matched ?? preview.locations?.length ?? 0} 处`;
226
+ if (call.name === 'fs.list')
227
+ return `${preview.matched ?? preview.locations?.length ?? 0} 项`;
228
+ if (typeof preview.replacements === 'number' && preview.replacements > 1)
229
+ return `${preview.replacements} 处`;
230
+ return '';
231
+ }
232
+ function failureText(call) {
233
+ if (call.status !== 'failed' && call.status !== 'cancelled')
234
+ return '';
235
+ const label = call.status === 'cancelled' ? '已取消' : '失败';
236
+ const result = call.result;
237
+ if (typeof result === 'string' && result)
238
+ return `${label} · ${stripTerminalControls(result)}`;
239
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
240
+ const record = result;
241
+ const message = typeof record.message === 'string' ? record.message : undefined;
242
+ const code = typeof record.code === 'string' ? record.code : undefined;
243
+ const detail = message ?? code;
244
+ if (detail)
245
+ return `${label} · ${stripTerminalControls(detail)}`;
246
+ }
247
+ return label;
248
+ }
249
+ function resultLines(result, width) {
250
+ const text = stripTerminalControls(typeof result === 'string' ? result : safeJson(result));
251
+ const clipped = text.length > 500 ? `${text.slice(0, 500)}…` : text;
252
+ return fit(chalk.dim(clipped), width);
253
+ }
254
+ function diffStat(call) {
255
+ if (call.name !== 'fs.apply_patch' && call.name !== 'fs.apply_patches' && !isContentWrite(call))
256
+ return '';
257
+ const counts = countEdits(call);
258
+ if (counts.add === 0 && counts.del === 0)
259
+ return '';
260
+ return `${chalk.green(`+${counts.add}`)} ${chalk.red(`−${counts.del}`)}`;
261
+ }
262
+ function countEdits(call) {
263
+ const args = call.arguments ?? {};
264
+ const hunks = isContentWrite(call)
265
+ ? [{ find: '', replace: text(args, 'content') }]
266
+ : call.name === 'fs.apply_patches' ? patches(args) : [{ find: text(args, 'find'), replace: text(args, 'replace') }];
267
+ return hunks.reduce((sum, hunk) => {
268
+ if (isContentWrite(call))
269
+ return { add: sum.add + hunk.replace.split('\n').filter((line) => line.length > 0).length, del: sum.del };
270
+ for (const op of diffLines(hunk.find, hunk.replace)) {
271
+ if (op.op === '+')
272
+ sum.add += 1;
273
+ if (op.op === '-')
274
+ sum.del += 1;
275
+ }
276
+ return sum;
277
+ }, { add: 0, del: 0 });
278
+ }
279
+ function diffLines(before, after) {
280
+ return diffLineArrays(before.split('\n'), after.split('\n'));
281
+ }
282
+ function diffLineArrays(left, right) {
283
+ if (left.length * right.length > 20_000) {
284
+ // Trim shared edges before the quadratic diff so a long fragment with one
285
+ // tail edit still displays that edit, rather than an unrelated prefix.
286
+ let prefix = 0;
287
+ while (prefix < left.length && prefix < right.length && left[prefix] === right[prefix])
288
+ prefix++;
289
+ let suffix = 0;
290
+ while (suffix < left.length - prefix && suffix < right.length - prefix && left[left.length - suffix - 1] === right[right.length - suffix - 1])
291
+ suffix++;
292
+ if (prefix || suffix)
293
+ return [
294
+ ...left.slice(0, prefix).map((text, index) => ({ op: ' ', text, line: index + 1 })),
295
+ ...diffLineArrays(left.slice(prefix, left.length - suffix), right.slice(prefix, right.length - suffix)).map((op) => ({ ...op, line: op.line + prefix })),
296
+ ...left.slice(left.length - suffix).map((text, index) => ({ op: ' ', text, line: left.length - suffix + index + 1 })),
297
+ ];
298
+ // A linear fallback retains every operation; only the display window clips
299
+ // them, so hidden lines and the approval truncation warning stay accurate.
300
+ return [
301
+ ...left.map((text, index) => ({ op: '-', text, line: index + 1 })),
302
+ ...right.map((text, index) => ({ op: '+', text, line: index + 1 })),
303
+ ];
304
+ }
305
+ const scores = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
306
+ for (let i = left.length - 1; i >= 0; i -= 1) {
307
+ for (let j = right.length - 1; j >= 0; j -= 1) {
308
+ scores[i][j] = left[i] === right[j] ? scores[i + 1][j + 1] + 1 : Math.max(scores[i + 1][j], scores[i][j + 1]);
309
+ }
310
+ }
311
+ const ops = [];
312
+ let i = 0;
313
+ let j = 0;
314
+ while (i < left.length && j < right.length) {
315
+ if (left[i] === right[j]) {
316
+ ops.push({ op: ' ', text: left[i], line: i + 1 });
317
+ i += 1;
318
+ j += 1;
319
+ }
320
+ else if (scores[i + 1][j] >= scores[i][j + 1]) {
321
+ ops.push({ op: '-', text: left[i], line: i + 1 });
322
+ i += 1;
323
+ }
324
+ else {
325
+ ops.push({ op: '+', text: right[j], line: j + 1 });
326
+ j += 1;
327
+ }
328
+ }
329
+ while (i < left.length) {
330
+ ops.push({ op: '-', text: left[i], line: i + 1 });
331
+ i += 1;
332
+ }
333
+ while (j < right.length) {
334
+ ops.push({ op: '+', text: right[j], line: j + 1 });
335
+ j += 1;
336
+ }
337
+ return ops;
338
+ }
339
+ function windowOps(ops) {
340
+ const keep = new Set();
341
+ ops.forEach((op, index) => {
342
+ if (op.op === ' ')
343
+ return;
344
+ for (let cursor = index - contextLines; cursor <= index + contextLines; cursor += 1)
345
+ if (cursor >= 0 && cursor < ops.length)
346
+ keep.add(cursor);
347
+ });
348
+ const interesting = keep.size ? ops.filter((_, index) => keep.has(index)) : ops.slice(0, contextLines);
349
+ if (interesting.length <= lineCap)
350
+ return { shown: interesting, hidden: ops.length - interesting.length };
351
+ return { shown: interesting.slice(0, lineCap), hidden: ops.length - lineCap };
352
+ }
353
+ function patches(args) {
354
+ if (!Array.isArray(args.patches))
355
+ return [];
356
+ return args.patches.flatMap((patch) => {
357
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch))
358
+ return [];
359
+ const row = patch;
360
+ return [{ find: text(row, 'find'), replace: text(row, 'replace') }];
361
+ });
362
+ }
363
+ function shellCommand(args) {
364
+ const command = text(args, 'command');
365
+ const values = Array.isArray(args.args) ? args.args.map((arg) => shellToken(stripTerminalControls(String(arg)))) : [];
366
+ return [command, ...values].filter(Boolean).join(' ');
367
+ }
368
+ function stageTarget(args) {
369
+ const operation = text(args, 'operation') || text(args, 'op');
370
+ const path = text(args, 'path');
371
+ const draft = text(args, 'draftId');
372
+ if (operation && path)
373
+ return `${operation} ${path}`;
374
+ if (operation)
375
+ return draft ? `${operation} ${draft.slice(0, 8)}` : operation;
376
+ return path;
377
+ }
378
+ function prefixBlock(output, width) {
379
+ const rows = output.split('\n');
380
+ return rows.flatMap((row, index) => fit(`${index === 0 ? ' └ ' : ' '}${row}`, width));
381
+ }
382
+ function formatBytes(bytes) {
383
+ if (bytes < 1024)
384
+ return `${bytes} B`;
385
+ const kilobytes = bytes / 1024;
386
+ return kilobytes < 10 ? `${kilobytes.toFixed(1)} KB` : `${Math.round(kilobytes)} KB`;
387
+ }
388
+ function editableText(args) {
389
+ const parts = [];
390
+ if (typeof args.content === 'string')
391
+ parts.push(args.content);
392
+ if (typeof args.find === 'string')
393
+ parts.push(args.find);
394
+ if (typeof args.replace === 'string')
395
+ parts.push(args.replace);
396
+ for (const hunk of patches(args))
397
+ parts.push(hunk.find, hunk.replace);
398
+ return parts.join('');
399
+ }
400
+ function dumpArgs(args) {
401
+ const visible = Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'expectedHash' && key !== 'hash'));
402
+ const text = safeJson(visible);
403
+ if (text.length <= 1_000)
404
+ return { text, clipped: false };
405
+ return { text: `${text.slice(0, 1_000)}\n…`, clipped: true };
406
+ }
407
+ function clipLine(value) {
408
+ if (value.length <= lineWidth)
409
+ return { text: value, clipped: false };
410
+ return { text: `${value.slice(0, lineWidth)}…`, clipped: true };
411
+ }
412
+ function text(args, key) {
413
+ const value = args[key];
414
+ return typeof value === 'string' ? stripTerminalControls(value) : '';
415
+ }
416
+ function joinPair(left, right, separator) {
417
+ return [left, right].filter(Boolean).join(separator);
418
+ }
419
+ function shellToken(value) {
420
+ if (value.length === 0)
421
+ return "''";
422
+ if (/^[\w./:@%+=,~-]+$/.test(value))
423
+ return value;
424
+ return `'${value.replaceAll("'", `'\\''`)}'`;
425
+ }
426
+ function fit(line, width) {
427
+ if (stripAnsi(line).length <= width)
428
+ return [line];
429
+ return wrapAnsi(line, width, { hard: true, trim: false }).split('\n');
430
+ }
431
+ function record(value) {
432
+ if (!value || typeof value !== 'object' || Array.isArray(value))
433
+ return {};
434
+ return value;
435
+ }
436
+ function toolStatus(value) {
437
+ if (value === 'succeeded' || value === 'failed' || value === 'running' || value === 'cancelled')
438
+ return value;
439
+ return 'running';
440
+ }
441
+ function parsePreview(value) {
442
+ if (!value || typeof value !== 'object' || Array.isArray(value))
443
+ return undefined;
444
+ const row = value;
445
+ const preview = {};
446
+ if (typeof row.bytes === 'number')
447
+ preview.bytes = row.bytes;
448
+ if (typeof row.lines === 'number')
449
+ preview.lines = row.lines;
450
+ if (row.truncated === true)
451
+ preview.truncated = true;
452
+ if (typeof row.matched === 'number')
453
+ preview.matched = row.matched;
454
+ if (Array.isArray(row.locations))
455
+ preview.locations = row.locations.filter((item) => typeof item === 'string').slice(0, 5);
456
+ if (typeof row.exitCode === 'number' || row.exitCode === null)
457
+ preview.exitCode = row.exitCode;
458
+ if (typeof row.output === 'string')
459
+ preview.output = row.output;
460
+ if (typeof row.replacements === 'number')
461
+ preview.replacements = row.replacements;
462
+ return Object.keys(preview).length ? preview : undefined;
463
+ }
464
+ function safeJson(value) {
465
+ try {
466
+ return JSON.stringify(value, null, 2) ?? '';
467
+ }
468
+ catch {
469
+ return String(value);
470
+ }
471
+ }
@@ -2,6 +2,7 @@ import wrapAnsi from 'wrap-ansi';
2
2
  import chalk from 'chalk';
3
3
  import { stripTerminalControls } from './ansi.js';
4
4
  import { renderMarkdownToAnsi } from './markdown.js';
5
+ import { renderToolCalls } from './tool-view.js';
5
6
  /** One shared rendering and wrapping path; scroll offsets are actual terminal rows. */
6
7
  export function transcriptLines(messages, width, verbosity = 'normal') {
7
8
  const blocks = messages.map((message) => {
@@ -13,40 +14,10 @@ export function transcriptLines(messages, width, verbosity = 'normal') {
13
14
  const body = wrapAnsi(text, contentWidth, { hard: true, trim: false }).split('\n');
14
15
  return { id: message.id, role: message.role, lines: [chalk.blue.bold('❯ 你发来'), ...body.map((line) => ` ${line}`)] };
15
16
  }
16
- const parts = [];
17
17
  const tools = message.toolCalls ?? [];
18
- if (verbosity !== 'quiet' && tools.length) {
19
- const failed = tools.filter((tool) => tool.status === 'failed' || tool.status === 'cancelled');
20
- parts.push(chalk.dim(`工具 · ${tools.filter((tool) => tool.status === 'succeeded').length}/${tools.length} 已完成${failed.length ? ` · ${failed.length} 未成功` : ''}`));
21
- const statusLabel = { running: '正在调用', succeeded: '已完成', failed: '失败', cancelled: '已取消' };
22
- if (verbosity === 'verbose') {
23
- for (const tool of tools) {
24
- const detail = `${tool.name} · ${statusLabel[tool.status]}\n${JSON.stringify(tool.arguments ?? {}, null, 2)}${tool.result === undefined ? '' : `\n${typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}`}`;
25
- parts.push(chalk.dim(stripTerminalControls(detail)));
26
- }
27
- }
28
- else {
29
- const groups = new Map();
30
- for (const tool of tools) {
31
- const result = tool.result && typeof tool.result === 'object' && !Array.isArray(tool.result) ? tool.result : undefined;
32
- const error = tool.status === 'failed' || tool.status === 'cancelled'
33
- ? [result?.code, result?.message].filter((value) => typeof value === 'string').at(-1)
34
- : undefined;
35
- const key = `${tool.name}\0${tool.status}\0${error ?? ''}`;
36
- const group = groups.get(key);
37
- if (group)
38
- group.count++;
39
- else
40
- groups.set(key, { name: tool.name, status: tool.status, count: 1, ...(error ? { error } : {}) });
41
- }
42
- for (const group of groups.values()) {
43
- parts.push(chalk.dim(`${group.name}${group.count > 1 ? ` × ${group.count}` : ''} · ${statusLabel[group.status]}${group.error ? ` · ${group.error}` : ''}`));
44
- }
45
- }
46
- }
47
- if (text)
48
- parts.push(renderMarkdownToAnsi(text));
49
- const body = wrapAnsi(parts.join('\n'), contentWidth, { hard: true, trim: false }).split('\n');
18
+ const toolLines = tools.length ? toolSummary(tools, verbosity, contentWidth) : [];
19
+ const textLines = text ? wrapAnsi(renderMarkdownToAnsi(text), contentWidth, { hard: true, trim: false }).split('\n') : [];
20
+ const body = [...toolLines, ...(toolLines.length && textLines.length ? [''] : []), ...textLines];
50
21
  const streamLabel = message.streamStatus === 'streaming' ? chalk.yellow(' · 正在生成') : message.streamStatus === 'incomplete' ? chalk.red(' · 未完成') : '';
51
22
  return { id: message.id, role: message.role, lines: [chalk.cyan.bold('Pulse') + streamLabel, ...body.map((line) => ` ${line}`)] };
52
23
  }).filter((block) => block.lines.length > 0);
@@ -59,6 +30,14 @@ export function transcriptLines(messages, width, verbosity = 'normal') {
59
30
  return [...block.lines, ...separator];
60
31
  });
61
32
  }
33
+ function toolSummary(tools, verbosity, width) {
34
+ if (verbosity === 'quiet') {
35
+ const failed = tools.filter((tool) => tool.status === 'failed' || tool.status === 'cancelled').length;
36
+ const done = tools.filter((tool) => tool.status === 'succeeded').length;
37
+ return [chalk.dim(`工具 · ${done}/${tools.length} 已完成${failed ? ` · ${failed} 未成功` : ''}`)];
38
+ }
39
+ return renderToolCalls(tools, { verbosity, width }).lines;
40
+ }
62
41
  export function scrollAction(input, key) {
63
42
  if (key.pageUp || (key.ctrl && input === 'p'))
64
43
  return 'up';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
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.4.4",
39
+ "@hunterzhu/pulse-server": "0.4.6",
40
40
  "chalk": "^5.4.1",
41
41
  "highlight.js": "^11.10.0",
42
42
  "ink": "^7.1.1",