@yanglaofish/fishtools 0.5.0 → 0.5.2

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 (84) hide show
  1. package/README.md +4 -2
  2. package/manual/README.md +4 -2
  3. package/manual/docs/deployment.md +2 -2
  4. package/manual/docs/modules.md +2 -0
  5. package/manual/docs/plugin-development.md +3 -1
  6. package/manual/docs/releases.md +44 -6
  7. package/manual/docs/superpowers/plans/2026-10-13-command-shortcuts.md +107 -0
  8. package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +70 -0
  9. package/manual/docs/superpowers/reviews/2026-10-13-command-shortcuts-review.md +63 -0
  10. package/manual/docs/superpowers/specs/2026-10-12-workflow-variation-design.md +1 -1
  11. package/manual/docs/superpowers/specs/2026-10-13-command-shortcuts-design.md +56 -0
  12. package/manual/docs/superpowers/specs/2026-10-13-issue-4-workflow-menu.md +28 -0
  13. package/manual/docs/terminal-commands.md +64 -0
  14. package/manual/docs/verification.md +39 -1
  15. package/manual/packages/core/README.md +3 -1
  16. package/manual/packages/core/package.json +1 -0
  17. package/manual/packages/core/src/application.js +126 -20
  18. package/manual/packages/core/src/export-snapshot.js +20 -1
  19. package/manual/packages/core/src/version.js +1 -1
  20. package/manual/packages/fst-cli/README.md +8 -1
  21. package/manual/packages/fst-cli/package.json +1 -0
  22. package/manual/packages/fst-cli/src/terminal/command-menu.js +186 -0
  23. package/manual/packages/fst-cli/src/terminal/navigation.js +4 -3
  24. package/manual/packages/fst-cli/src/terminal/run-feed.js +24 -9
  25. package/manual/packages/fst-cli/src/terminal/session.js +35 -4
  26. package/manual/packages/fst-cli/src/terminal/view.js +43 -8
  27. package/manual/packages/fst-cli/src/terminal/workflow-menu.js +1 -6
  28. package/manual/packages/fst-devtools/src/documentation.js +2 -1
  29. package/manual/packages/fst-logs/README.md +11 -0
  30. package/manual/packages/fst-logs/package.json +2 -0
  31. package/manual/packages/fst-logs/src/run-logs.js +8 -23
  32. package/manual/packages/fst-logs/src/run-preview.js +18 -0
  33. package/manual/packages/fst-logs/src/step-summaries.js +28 -0
  34. package/manual/packages/fst-platform/README.md +17 -0
  35. package/manual/packages/fst-platform/package.json +1 -0
  36. package/manual/packages/fst-platform/src/process.js +12 -8
  37. package/manual/packages/fst-platform/src/shell.js +58 -0
  38. package/manual/packages/fst-plugin/src/contracts.js +6 -2
  39. package/manual/packages/fst-plugin/src/host.js +7 -4
  40. package/manual/packages/fst-plugin/src/runtime.js +8 -5
  41. package/manual/packages/fst-workspace/README.md +3 -0
  42. package/manual/packages/fst-workspace/package.json +1 -0
  43. package/manual/packages/fst-workspace/src/shortcuts.js +119 -0
  44. package/manual/packages/pack/index.js +1 -1
  45. package/manual/plugins/commands/LICENSE +21 -0
  46. package/manual/plugins/commands/README.md +41 -0
  47. package/manual/plugins/commands/index.js +94 -0
  48. package/manual/plugins/commands/package.json +17 -0
  49. package/node_modules/@mytools/commands/LICENSE +21 -0
  50. package/node_modules/@mytools/commands/README.md +41 -0
  51. package/node_modules/@mytools/commands/index.js +94 -0
  52. package/node_modules/@mytools/commands/package.json +17 -0
  53. package/node_modules/@mytools/core/README.md +3 -1
  54. package/node_modules/@mytools/core/package.json +1 -0
  55. package/node_modules/@mytools/core/src/application.js +126 -20
  56. package/node_modules/@mytools/core/src/export-snapshot.js +20 -1
  57. package/node_modules/@mytools/core/src/version.js +1 -1
  58. package/node_modules/@mytools/fst-cli/README.md +8 -1
  59. package/node_modules/@mytools/fst-cli/package.json +1 -0
  60. package/node_modules/@mytools/fst-cli/src/terminal/command-menu.js +186 -0
  61. package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +4 -3
  62. package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +24 -9
  63. package/node_modules/@mytools/fst-cli/src/terminal/session.js +35 -4
  64. package/node_modules/@mytools/fst-cli/src/terminal/view.js +43 -8
  65. package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +1 -6
  66. package/node_modules/@mytools/fst-devtools/src/documentation.js +2 -1
  67. package/node_modules/@mytools/fst-logs/README.md +11 -0
  68. package/node_modules/@mytools/fst-logs/package.json +2 -0
  69. package/node_modules/@mytools/fst-logs/src/run-logs.js +8 -23
  70. package/node_modules/@mytools/fst-logs/src/run-preview.js +18 -0
  71. package/node_modules/@mytools/fst-logs/src/step-summaries.js +28 -0
  72. package/node_modules/@mytools/fst-platform/README.md +17 -0
  73. package/node_modules/@mytools/fst-platform/package.json +1 -0
  74. package/node_modules/@mytools/fst-platform/src/process.js +12 -8
  75. package/node_modules/@mytools/fst-platform/src/shell.js +58 -0
  76. package/node_modules/@mytools/fst-plugin/src/contracts.js +6 -2
  77. package/node_modules/@mytools/fst-plugin/src/host.js +7 -4
  78. package/node_modules/@mytools/fst-plugin/src/runtime.js +8 -5
  79. package/node_modules/@mytools/fst-workspace/README.md +3 -0
  80. package/node_modules/@mytools/fst-workspace/package.json +1 -0
  81. package/node_modules/@mytools/fst-workspace/src/shortcuts.js +119 -0
  82. package/node_modules/@mytools/pack/index.js +1 -1
  83. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1212 -1187
  84. package/package.json +3 -1
@@ -0,0 +1,186 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {isCommandName} from '@mytools/fst-plugin/contracts';
3
+ import {editValue} from './schema.js';
4
+
5
+ export async function runShortcut(session,entry,args=[]){
6
+ if(!entry.available)throw new Error(`快捷名称 /${entry.name} 已失效:${entry.reason??'目标不存在'}`);
7
+ const {app}=session,target=entry.target;
8
+ if(target.command){
9
+ const command=app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
10
+ if(!command)throw new Error(`快捷名称 /${entry.name} 的目标命令不存在`);
11
+ return session.pluginCommand({...command,name:entry.name,targetName:command.name,shortcutInput:entry.input??{}},args);
12
+ }
13
+ const action=app.catalog().find(action=>action.id===target.action);
14
+ if(!action)throw new Error(`快捷名称 /${entry.name} 的目标动作不存在`);
15
+ const plugin=action.plugin??action.id.slice(0,action.id.lastIndexOf(':'));
16
+ return session.pluginCommand({...action,name:entry.name,plugin,action:action.id.slice(plugin.length+1),actionId:action.id,shortcutInput:entry.input??{}},args);
17
+ }
18
+
19
+ async function parameters(session,value={}){
20
+ const result=structuredClone(value),{ui}=session;
21
+ for(;;){
22
+ const choice=await ui.select('命令参数(脚本通过 FST_INPUT_参数名 环境变量读取)',[
23
+ ...Object.entries(result).map(([name,field])=>({value:`field:${name}`,label:field.title??name,description:`${name} · ${field.type}`})),
24
+ {value:'new',label:'添加参数'},{value:'back',label:'完成参数设置'},
25
+ ]);
26
+ if(choice==='back')return result;
27
+ const existing=choice.startsWith('field:')?choice.slice(6):undefined;
28
+ if(existing){
29
+ const operation=await ui.select(`参数 ${existing}`,[{value:'edit',label:'编辑'},{value:'remove',label:'删除'},{value:'back',label:'返回'}]);
30
+ if(operation==='back')continue;
31
+ if(operation==='remove'){delete result[existing];continue;}
32
+ }
33
+ const name=existing??(await ui.input('参数名(英文字母、数字、下划线,不能以数字开头)',{files:false})).trim();
34
+ if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)||['__proto__','constructor','prototype'].includes(name)){ui.show('参数名无效,请重新填写。');continue;}
35
+ if(!existing&&Object.hasOwn(result,name)){ui.show('参数名已经存在。');continue;}
36
+ const title=await ui.input('参数显示名称',{value:result[name]?.title??name,files:false});
37
+ const type=await ui.select('参数类型',[{value:'string',label:'文本'},{value:'number',label:'数字'},{value:'boolean',label:'是 / 否'}]);
38
+ const field={title,type,required:await ui.confirm('这个参数必须填写吗?')};
39
+ if(await ui.confirm('设置默认值吗?'))field.default=await editValue(ui,{type},result[name]?.default,{baseDir:session.baseDir,required:true,label:'默认值'});
40
+ result[name]=field;
41
+ }
42
+ }
43
+
44
+ async function editPreset(session,id,current){
45
+ const {ui,app}=session,preset=structuredClone(current??{shell:'auto'});
46
+ preset.title=(await ui.input('常用命令名称',{value:preset.title??'',files:false})).trim();
47
+ preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script??'',files:false,multiline:true});
48
+ const action=`@mytools/commands:${id}`;
49
+ const oldName=Object.keys(app.project.shortcuts??{}).find(name=>app.project.shortcuts[name].action===action);
50
+ let shortcut=oldName??(isCommandName(preset.title)?preset.title:'');
51
+ for(;;){
52
+ const choice=await ui.select('常用命令设置',[
53
+ {value:'save',label:'保存',description:shortcut?`快捷执行 /${shortcut}`:'只注册工作流动作'},
54
+ {value:'title',label:'修改名称'},{value:'script',label:'编辑多行命令'},
55
+ {value:'shortcut',label:'快捷名称',description:shortcut||'未设置'},
56
+ {value:'shell',label:'命令解释器',description:preset.shell??'auto'},
57
+ {value:'timeout',label:'超时时间',description:preset.timeout===0?'不限时':`${(preset.timeout??30000)/1000} 秒`},
58
+ {value:'parameters',label:'参数',description:`${Object.keys(preset.parameters??{}).length} 项,可不设置`},
59
+ {value:'root',label:'Linux root 权限要求',description:preset.requireRoot?'需要 root':'按当前用户权限执行'},
60
+ {value:'back',label:'取消,返回'},
61
+ ]);
62
+ if(choice==='back')return;
63
+ if(choice==='title')preset.title=(await ui.input('常用命令名称',{value:preset.title,files:false})).trim();
64
+ else if(choice==='script')preset.script=await ui.input('命令文本 · Shift+Enter 换行,Enter 提交(Alt+Enter 备用)',{value:preset.script,files:false,multiline:true});
65
+ else if(choice==='shortcut')shortcut=(await ui.input('快捷名称(支持中文,不含空格或 /;留空不设置)',{value:shortcut,files:false})).trim();
66
+ else if(choice==='shell')preset.shell=await ui.select('命令解释器',[
67
+ {value:'auto',label:'按系统选择(Windows PowerShell / Linux sh)'},
68
+ {value:'powershell',label:'Windows PowerShell'},{value:'pwsh',label:'PowerShell 7(pwsh)'},
69
+ {value:'sh',label:'sh'},{value:'bash',label:'bash'},
70
+ ]);
71
+ else if(choice==='timeout'){
72
+ const seconds=Number(await ui.input('超时时间(秒,0 表示不限时)',{value:String((preset.timeout??30000)/1000),files:false}));
73
+ if(!Number.isFinite(seconds)||!Number.isSafeInteger(seconds*1000)||seconds<0||seconds*1000>2147483647){ui.show('超时时间无效。');continue;}
74
+ preset.timeout=seconds*1000;
75
+ }else if(choice==='parameters')preset.parameters=await parameters(session,preset.parameters);
76
+ else if(choice==='root')preset.requireRoot=await ui.confirm('这个命令在 Linux 上必须使用 root 吗?执行前会检查,不会自动提权。');
77
+ else if(choice==='save'){
78
+ const next={...app.project.commandPresets,[id]:preset},shortcuts=structuredClone(app.project.shortcuts??{});
79
+ if(oldName)delete shortcuts[oldName];
80
+ if(shortcut){
81
+ if(Object.hasOwn(shortcuts,shortcut)){ui.show(`快捷名称 /${shortcut} 已存在,请选择其他名称。`);continue;}
82
+ shortcuts[shortcut]={...(oldName?app.project.shortcuts[oldName]:{}),action};
83
+ }
84
+ try{
85
+ const result=await session.withMaintenance(options=>app.configureCommandPresets(next,{...options,shortcuts}));
86
+ if(result===null){ui.show('已取消保存。');continue;}
87
+ ui.show(`常用命令已保存,修改前配置已备份。${shortcut?`输入 /${shortcut} 可直接运行。`:''}也可在工作流中添加此动作。`);
88
+ return;
89
+ }catch(error){ui.show(`保存失败:${error.message}`);}
90
+ }
91
+ }
92
+ }
93
+
94
+ export async function commandPresetsMenu(session){
95
+ const {app,ui}=session;
96
+ for(;;){
97
+ const choice=await ui.select('常用命令',[
98
+ ...(!app.project.readOnly?[{value:'new',label:'注册常用命令'}]:[]),
99
+ ...Object.entries(app.project.commandPresets??{}).map(([id,preset])=>({value:`preset:${id}`,label:preset.title,description:id})),
100
+ {value:'back',label:'返回主面板'},
101
+ ]);
102
+ if(choice==='back')return;
103
+ if(choice==='new'){session.writable();await editPreset(session,`command-${randomUUID().slice(0,8)}`);continue;}
104
+ const id=choice.slice(7),preset=app.project.commandPresets?.[id];if(!preset)continue;
105
+ const operation=await ui.select(preset.title,[{value:'run',label:'运行'},
106
+ ...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除注册项'}]:[]),{value:'back',label:'返回'}]);
107
+ if(operation==='run')await runShortcut(session,{name:preset.title,target:{action:`@mytools/commands:${id}`},input:{},available:true});
108
+ else if(operation==='edit'){session.writable();await editPreset(session,id,preset);}
109
+ else if(operation==='remove'){
110
+ session.writable();if(!await ui.confirm(`删除 ${preset.title}?指向它的快捷名称会显示失效;引用中的工作流需先调整。`))continue;
111
+ const next=structuredClone(app.project.commandPresets);delete next[id];
112
+ try{const result=await session.withMaintenance(options=>app.configureCommandPresets(next,options));if(result!==null)ui.show('注册项已删除,修改前配置已备份。');}
113
+ catch(error){ui.show(`删除失败:${error.message}`);}
114
+ }
115
+ }
116
+ }
117
+
118
+ function targets(app){
119
+ return [...app.catalog().map(action=>({value:`action:${action.id}`,label:`动作:${action.title??action.id}`,description:action.id})),
120
+ ...app.listCommands().map(command=>({value:`command:${command.name}`,label:`命令:/${command.name}`,description:command.description}))];
121
+ }
122
+ function shortcutTarget(app,selected){
123
+ if(selected.startsWith('action:'))return {action:selected.slice(7)};
124
+ const name=selected.slice(8),command=app.listCommands().find(command=>command.name===name);
125
+ if(!command)throw new Error('目标命令已失效,请重新选择');
126
+ return {command:name,plugin:command.plugin};
127
+ }
128
+ async function editShortcut(session,oldName){
129
+ const {app,ui}=session,old=app.project.shortcuts?.[oldName];
130
+ let name=(await ui.input('快捷名称(支持中文,不含空格或 /)',{value:oldName??'',files:false})).trim();
131
+ let target=old?structuredClone(old):{};
132
+ if(!old){
133
+ const choices=targets(app);if(!choices.length){ui.show('当前没有可选择的插件动作或命令。');return;}
134
+ const selected=await ui.select('选择快捷名称指向的能力',choices);
135
+ target=shortcutTarget(app,selected);
136
+ }
137
+ for(;;){
138
+ const choice=await ui.select(`快捷名称 /${name}`,[
139
+ {value:'save',label:'保存'},{value:'name',label:'修改快捷名称'},
140
+ {value:'target',label:'选择目标',description:target.action??target.command},
141
+ {value:'input',label:'设置默认参数',description:'可留空,运行时按需要填写'},
142
+ {value:'back',label:'取消,返回'},
143
+ ]);
144
+ if(choice==='back')return;
145
+ if(choice==='name')name=(await ui.input('快捷名称',{value:name,files:false})).trim();
146
+ else if(choice==='target'){
147
+ const selected=await ui.select('选择目标',targets(app));
148
+ target=shortcutTarget(app,selected);
149
+ }else if(choice==='input'){
150
+ const metadata=target.action?app.catalog().find(action=>action.id===target.action):app.listCommands().find(command=>command.name===target.command&&command.plugin===target.plugin);
151
+ if(!metadata){ui.show('目标已失效,请先重新选择目标。');continue;}
152
+ target.input=await editValue(ui,metadata.input??{type:'object',properties:{},additionalProperties:false},target.input??{},{baseDir:session.baseDir,required:true,label:'快捷名称默认参数'});
153
+ }else if(choice==='save'){
154
+ const next=structuredClone(app.project.shortcuts??{});if(oldName)delete next[oldName];
155
+ if(Object.hasOwn(next,name)){ui.show('快捷名称已经存在,请选择其他名称。');continue;}
156
+ next[name]=target;
157
+ try{await app.configureShortcuts(next);ui.show('快捷名称已保存,修改前配置已备份。');return;}
158
+ catch(error){ui.show(`保存失败:${error.message}`);}
159
+ }
160
+ }
161
+ }
162
+
163
+ export async function shortcutsMenu(session){
164
+ const {app,ui}=session;
165
+ for(;;){
166
+ const entries=app.listShortcuts(),choice=await ui.select('自定义快捷名称',[
167
+ ...(!app.project.readOnly?[{value:'new',label:'添加快捷名称'}]:[]),
168
+ ...entries.map(entry=>({value:`shortcut:${entry.name}`,label:`/${entry.name}${entry.available?'':' [失效]'}`,description:entry.available?entry.description:entry.reason})),
169
+ {value:'back',label:'返回主面板'},
170
+ ]);
171
+ if(choice==='back')return;
172
+ if(choice==='new'){session.writable();await editShortcut(session);continue;}
173
+ const name=choice.slice(9),entry=entries.find(entry=>entry.name===name);if(!entry)continue;
174
+ const operation=await ui.select(`/${name}`,[
175
+ ...(entry.available?[{value:'run',label:'运行'}]:[]),
176
+ ...(!app.project.readOnly?[{value:'edit',label:'编辑'},{value:'remove',label:'删除快捷名称'}]:[]),{value:'back',label:'返回'},
177
+ ]);
178
+ if(operation==='run')await runShortcut(session,entry);
179
+ else if(operation==='edit'){session.writable();await editShortcut(session,name);}
180
+ else if(operation==='remove'){
181
+ session.writable();if(!await ui.confirm(`删除快捷名称 /${name}?`))continue;
182
+ const next=structuredClone(app.project.shortcuts??{});delete next[name];
183
+ await app.configureShortcuts(next);ui.show('快捷名称已删除,插件能力仍可使用。');
184
+ }
185
+ }
186
+ }
@@ -1,10 +1,11 @@
1
1
  import {builtinCommands} from '@mytools/fst-plugin/contracts';
2
2
 
3
- const visible=['workflows','run','pack','logs','plugin','config','tools','help','exit'];
3
+ const visible=['workflows','run','pack','logs','plugin','commands','shortcuts','config','tools','help','exit'];
4
4
  // 入口由插件 ID 推导;冲突时加前缀,不能覆盖内置或作者注册的命令。
5
- export function commandMenu(plugins,commands){
5
+ export function commandMenu(plugins,commands,shortcuts=[]){
6
6
  const entries=builtinCommands.filter(command=>visible.includes(command.name)).map(command=>({...command,description:`[内置] ${command.description}`}));
7
- const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name)]);
7
+ const used=new Set([...builtinCommands.map(c=>c.name),...commands.map(c=>c.name),...shortcuts.map(entry=>entry.name)]);
8
+ entries.push(...shortcuts.map(entry=>({name:entry.name,shortcut:true,description:`[快捷] ${entry.available?entry.description:`已失效:${entry.reason}`}`})));
8
9
  const groups=plugins.filter(plugin=>commands.some(c=>c.plugin===plugin.id)).sort((a,b)=>a.id.localeCompare(b.id));
9
10
  for(const plugin of groups){
10
11
  const leaf=plugin.id.split('/').at(-1).replace(/[^\w.-]/g,'-');
@@ -1,12 +1,21 @@
1
1
  import {readableValue,readableError,readableTimeline} from './run-presentation.js';
2
2
  import {runStatuses} from './run-viewer.js';
3
+ import {createStepSummaries} from '@mytools/fst-logs/steps';
4
+ import {previewRun} from '@mytools/fst-logs/preview';
5
+
6
+ const feedRecord=(record,maxBytes)=>{
7
+ const {stepSummaries,stepSummaryBytes,stepsTruncated,...state}=record;
8
+ return previewRun({...state,events:[...(state.events??[])]},{maxValueBytes:Math.min(maxBytes,65536)});
9
+ };
3
10
 
4
11
  // 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
5
12
  export class RunFeed{
6
13
  constructor(refresh,{maxEvents=1000,maxBytes=1048576,maxRuns=50}={}){Object.assign(this,{refresh,maxEvents,maxBytes,maxRuns});this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;this.dropped=0;}
7
14
  track({app,runId,title,input}){
8
15
  if(this.runs.has(runId))return;
9
- const record=app.getRun(runId),item={app,record,title,input,count:0,ended:false,steps:new Map(),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
16
+ const raw=app.getRun(runId),record=feedRecord(raw,this.maxBytes),bounded=previewRun({input},{maxValueBytes:Math.min(this.maxBytes,65536)});
17
+ const item={app,record,title,input:bounded.input,inputTruncated:bounded.inputTruncated,count:0,ended:false,steps:createStepSummaries({maxEvents:this.maxEvents,maxMemoryBytes:this.maxBytes}),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
18
+ for(const summary of raw.stepSummaries??[])item.steps.append(summary);
10
19
  this.runs.set(runId,item);
11
20
  this.entries.push({type:'start',item});
12
21
  if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
@@ -30,12 +39,13 @@ export class RunFeed{
30
39
  if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type)){item.record.events.push(event);events=[event];item.count++;}
31
40
  else{
32
41
  const record=item.app.getRun(item.record.id);if(!record)return;
33
- item.record={...record,events:[...(record.events??[])]};
42
+ item.record=feedRecord(record,this.maxBytes);
43
+ for(const summary of record.stepSummaries??[])item.steps.append(summary);
34
44
  events=record.events.filter((e,index)=>e.seq!==undefined?e.seq>item.count:index>=item.count);
35
45
  item.count=record.eventCount??record.events.length;
36
46
  }
37
47
  const {record}=item;
38
- for(const event of events)if(event.type.startsWith('step.'))item.steps.set(event.callPath,{...item.steps.get(event.callPath),...event});
48
+ for(const event of events)item.steps.append(event);
39
49
  if(events.length){
40
50
  const last=this.entries.at(-1);
41
51
  if(last?.type==='events'&&last.item===item)last.events.push(...events);
@@ -59,7 +69,6 @@ export class RunFeed{
59
69
  this.entries=this.entries.filter(entry=>!entry.events||entry.events.length);
60
70
  for(const item of this.runs.values()){
61
71
  item.record.events=this.entries.filter(e=>e.item===item&&e.events).flatMap(e=>e.events);
62
- while(item.steps.size>this.maxEvents)item.steps.delete(item.steps.keys().next().value);
63
72
  }
64
73
  const ended=[...this.runs.values()].filter(item=>item.ended);
65
74
  for(const item of ended.slice(0,Math.max(0,ended.length-this.maxRuns))){this.runs.delete(item.record.id);this.entries=this.entries.filter(e=>e.item!==item);this.dropped++;}
@@ -75,10 +84,14 @@ export class RunFeed{
75
84
  for(const event of entry.events){
76
85
  const key=`${item.record.id}\0${event.callPath}`;
77
86
  if(event.type.startsWith('step.')&&!seen.has(key)){
78
- seen.add(key);const latest=item.steps.get(event.callPath),action=item.catalog.get(latest.action);
79
- const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
80
- const name=action?.title??latest.action??latest.stepId??latest.callPath;
81
- rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
87
+ seen.add(key);const latest=item.steps.get(event.callPath);
88
+ if(!latest)rows.push(`· ${event.action??event.stepId??event.callPath} · 摘要已省略 · ${event.callPath}`);
89
+ else{
90
+ const action=item.catalog.get(latest.action);
91
+ const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
92
+ const name=action?.title??latest.action??latest.stepId??latest.callPath;
93
+ rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
94
+ }
82
95
  }
83
96
  if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
84
97
  if(event.type==='step.failed'&&![...item.steps.values()].some(step=>step.type==='step.failed'&&step.callPath?.startsWith(event.callPath+'/')))rows.push(`✗ ${event.callPath}\n${readableError(event.error??{message:event.message})}`);
@@ -86,18 +99,20 @@ export class RunFeed{
86
99
  }
87
100
  return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
88
101
  }
89
- text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
102
+ text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;超出预算的事件和步骤摘要已省略。完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
90
103
  if(entry.type==='message')return entry.text;
91
104
  const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
92
105
  if(entry.type==='start')return [
93
106
  `› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
94
107
  `状态:${runStatuses[record.status]??record.status}`,
95
108
  this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
109
+ this.expanded&&entry.item.inputTruncated?'输入较大,主面板仅显示摘要。':'',
96
110
  ].filter(Boolean).join('\n');
97
111
  if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
98
112
  return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
99
113
  ...(record.warnings??[]).map(warning=>`警告:${warning.message}`),
100
114
  this.expanded&&record.output!==undefined?readableValue(record.output):'',record.error&&(this.expanded||![...entry.item.steps.values()].some(step=>step.type==='step.failed'))?readableError(record.error,{details:this.expanded}):'',record.reason??'',
115
+ this.expanded&&record.outputTruncated?'结果较大,主面板仅显示摘要;完整结果请在 /logs 中按 p 分页阅读。':'',
101
116
  ].filter(Boolean).join('\n');
102
117
  }).filter(Boolean).join('\n\n');}
103
118
  close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
@@ -9,6 +9,8 @@ import {gettingStarted,pluginStatus,pluginDetails} from './presentation.js';
9
9
  import {readableValue,readableError} from './run-presentation.js';
10
10
  import {commandMenu} from './navigation.js';
11
11
  import {workflowMenu} from './workflow-menu.js';
12
+ import {commandPresetsMenu,shortcutsMenu,runShortcut} from './command-menu.js';
13
+ import {validate} from '@mytools/sdk/schema';
12
14
  import {resolvePrivilegeRequirement,checkStaticPrivilege} from '@mytools/fst-plugin/privileges';
13
15
 
14
16
  export class TerminalSession{
@@ -24,7 +26,7 @@ export class TerminalSession{
24
26
  }
25
27
  }
26
28
  current(){if(!this.flow)throw new Error('先用 /workflows 打开或新建工作流');return this.flow;}
27
- commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands());}
29
+ commands(){return commandMenu(this.app.listPlugins?.()??[],this.app.listCommands(),this.app.listShortcuts?.()??[]);}
28
30
  async discard(){return !this.dirty||await this.ui.confirm('当前修改未保存,是否放弃修改?');}
29
31
  async mayExit(){
30
32
  if(!await this.discard())return false;
@@ -41,7 +43,7 @@ export class TerminalSession{
41
43
  while(!this.exiting){
42
44
  this.ui.summary(`欢迎使用 Fishtools · 插件编排工作台\n当前工作区:${this.app.projectDir}\n${this.app.listPlugins().length} 个插件 · ${(await this.app.listWorkflows()).length} 个工作流${this.flow?` · ${this.flow.title??this.flow.id} · ${this.dirty?'未保存':'已保存'}`:''}`);
43
45
  let text;
44
- try{text=await this.ui.input('fst > 输入 / 选择操作,@ 引用文件',{commands:this.commands(),workflows:await this.app.listWorkflows()});}
46
+ try{text=await this.ui.input('fst > / 操作 · ! 系统命令 · Shift+Enter 换行 · Enter 提交',{commands:this.commands(),workflows:await this.app.listWorkflows(),multiline:true});}
45
47
  catch(e){if(e.code!=='PROMPT_CANCELLED')throw e;try{if(await this.mayExit())break;}catch{}continue;}
46
48
  if(!text.trim())continue;
47
49
  try{await this.execute(text);}catch(e){if(e.code!=='PROMPT_CANCELLED'||!this.ui.endCommand)this.ui.show(e.code==='PROMPT_CANCELLED'?'已返回主面板。':`${e.code==='COMMAND_INPUT'?'输入提示':'操作失败'}:${e.message}`);}
@@ -57,19 +59,25 @@ export class TerminalSession{
57
59
  finally{this.commandDepth--;if(outer&&!this.exiting)this.ui.endCommand?.();}
58
60
  }
59
61
  async executeCommand(text){
62
+ if(text.trimStart().startsWith('!'))return this.shellCommand(text.trimStart().slice(1));
60
63
  const [command,...args]=parseCommand(text);
64
+ const shortcut=this.app.listShortcuts?.().find(entry=>entry.name===command);
65
+ if(shortcut)return runShortcut(this,shortcut,args);
61
66
  const entry=this.commands().find(c=>c.name===command&&c.plugin);
62
67
  if(entry){if(args.length)throw new Error(`/${command} 请从二级菜单选择能力`);return this.pluginSkills(entry.plugin);}
63
68
  const pluginCommand=this.app.listCommands().find(c=>c.name===command);
64
69
  if(pluginCommand)return this.pluginCommand(pluginCommand,args);
65
70
  if(command!=='run'&&args.length)throw new Error(`/${command} 使用交互选择,请不要附加参数`);
66
71
  switch(command){
72
+ case 'commands':return commandPresetsMenu(this);
73
+ case 'shortcuts':return shortcutsMenu(this);
67
74
  case 'help':await this.ui.viewMarkdown('Fishtools 使用指南',[
68
75
  '# Fishtools',
69
76
  '将插件能力组合为工作流,在终端内编辑、运行并查看结果。',
70
77
  `当前工作区:${this.app.projectDir}`,
71
78
  `文件路径起点:${this.baseDir}`,
72
79
  '## 常用操作',gettingStarted(await this.app.listWorkflows(),this.app.project),
80
+ '## 系统命令与快捷名称','输入 ! 后接系统命令可直接执行,输出和状态保留在主面板;/commands 注册常用命令供快捷执行与工作流编排,/shortcuts 给任意插件动作或命令设置中文快捷名称。主输入框和命令编辑框使用 Shift+Enter 换行、Enter 提交,Alt+Enter 为备用换行;支持多行粘贴。系统命令按当前 Shell 语法执行,目标机器需要已安装相应外部程序。',
73
81
  '## 编排工作流','/workflows 浏览、新建、打开工作流,进入后添加步骤、编辑参数、设置输出、保存或删除。删除会保留备份,仍被其他流程引用时不能删除。/run 选择已保存的工作流,或补全工作流 ID/中文标题后执行已保存版本,不会要求保存编辑草稿。要执行未保存修改,在工作流编辑菜单选择运行,再选择保存并运行;返回编辑会保留草稿。',
74
82
  '## 主面板','运行期间可以继续输入命令,打开配置、管理插件或执行其他工作流。↑/↓ 回看本次启动已提交的命令,回到底部恢复草稿;补全菜单打开时仍用于选择候选。Ctrl+O 展开或收起执行详情,PgUp/PgDn 或滚轮浏览执行过程;/logs 回看各次执行的步骤、输入输出和日志。关闭菜单不会清空运行内容。',
75
83
  '## 命令分层','/config 配置 Fishtools,/plugin 管理插件及各插件配置。输入 /插件入口名 后进入该插件的能力菜单;补全列表标明 [内置] 或 [插件]。/tools 包含 Web 与离线文档。旧 /new、/add、/steps、/outputs、/save、/plugins 以及插件的原始命令仍可直接输入。',
@@ -118,14 +126,26 @@ export class TerminalSession{
118
126
  }
119
127
  async pluginCommand(command,args){
120
128
  if(args.length>1)throw new Error(`用法:/${command.name} [@参数.json]`);
121
- const input=args.length?await readRunInput(args[0],this.baseDir):command.input?await editValue(this.ui,command.input,{}, {baseDir:this.baseDir,required:true,label:command.description}):{};
129
+ let input;
130
+ if(args.length)input=await readRunInput(args[0],this.baseDir);
131
+ else{
132
+ const initial=structuredClone(command.shortcutInput??{});
133
+ const empty=command.input?.type==='object'&&command.input.properties&&Object.keys(command.input.properties).length===0;
134
+ if(command.shortcutInput!==undefined||empty){
135
+ try{input=validate(command.input,initial,command.description??command.name);}
136
+ catch{input=await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description});}
137
+ }else input=command.input?await editValue(this.ui,command.input,initial,{baseDir:this.baseDir,required:true,label:command.description}):initial;
138
+ }
122
139
  const ui=Object.fromEntries(['input','select','show','confirm'].map(name=>[name,this.ui[name].bind(this.ui)]));
123
140
  const definition=this.app.host.definitions.get(command.plugin);
141
+ if(!definition)throw new Error('目标插件已移除,请重新选择目标后执行');
124
142
  const requirement=resolvePrivilegeRequirement(definition,command.action);
125
143
  const unknown=requirement.declared&&checkStaticPrivilege(requirement).status==='unknown';
126
144
  const allowUnknownPrivileges=unknown?await this.ui.confirm(`/${command.name} 的权限需要运行时核查。若检查结果仍无法确认,是否允许本次继续?\n${requirement.reason??''}\n${requirement.remediation??''}`):false;
127
145
  if(unknown&&!allowUnknownPrivileges)return;
128
- const run=await this.app.startCommand(command.name,input,{ui,allowUnknownPrivileges});
146
+ const targetName=command.targetName??command.name;
147
+ if(!command.actionId&&!this.app.listCommands().some(current=>current.name===targetName&&current.plugin===command.plugin))throw new Error('命令目标插件已变更,请重新选择目标后执行');
148
+ const run=command.actionId?await this.app.startAction(command.actionId,input,{ui,allowUnknownPrivileges}):await this.app.startCommand(targetName,input,{ui,allowUnknownPrivileges,expectedPlugin:command.plugin});
129
149
  if(this.ui.trackRun){
130
150
  this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:command.description??command.name,input});
131
151
  if(command.action){void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;}
@@ -135,6 +155,17 @@ export class TerminalSession{
135
155
  try{const result=await run.done;if(!this.ui.trackRun)this.ui.show(`插件执行${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看执行详情与历史日志`);}
136
156
  finally{unsubscribe();done();}
137
157
  }
158
+ async shellCommand(script){
159
+ const run=await this.app.startShell(script);
160
+ if(this.ui.trackRun){
161
+ this.ui.clearMessages?.();this.ui.trackRun({app:this.app,runId:run.id,title:'系统命令'});
162
+ void run.done.catch(error=>{if(!this.ui.closed)this.ui.show(`运行记录保存失败:${error.message}`);});return;
163
+ }
164
+ const unsubscribe=this.app.subscribe(event=>{if(event.runId===run.id&&event.type==='log')this.ui.show(event.message);});
165
+ const done=this.ui.busy('正在执行系统命令 · Ctrl+C 取消',()=>this.app.cancelRun(run.id));
166
+ try{const result=await run.done;this.ui.show(`命令${runStatuses[result.status]??result.status}\n${result.error?readableError(result.error):readableValue(result.output)}\n/logs 查看完整日志`);}
167
+ finally{unsubscribe();done();}
168
+ }
138
169
  async configure(selectedId){
139
170
  this.writable();
140
171
  const plugins=this.app.listPlugins().filter(p=>p.config);
@@ -76,7 +76,20 @@ export class TerminalView{
76
76
  input(data);
77
77
  },resize);
78
78
  this.header=new Text('',1,0);this.transcript=new Text('',1,0);this.prompt=new Container();this.hint=new Text('',1,0);
79
- this.hint.render=width=>['',fitLine(' '+palette.muted(this.homeInput?(width<45?`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出')),width)];
79
+ this.hint.render=width=>{
80
+ const lines=[''];
81
+ if(this.editor&&(this.homeInput||this.multilineInput)){
82
+ // 按完整按键分组换行,窄屏也能看清主键和备用键。
83
+ let line='';
84
+ for(const hint of ['Shift+Enter 换行','Enter 提交','Alt+Enter 备用换行']){
85
+ const next=line?line+' · '+hint:hint;
86
+ if(line&&visibleWidth(next)>width-1){lines.push(line);line=hint;}else line=next;
87
+ }
88
+ lines.push(line);
89
+ }
90
+ lines.push(this.homeInput?(width<45?`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}`:`/ 操作 · ! 命令 · Ctrl+O ${this.feed.expanded?'收起':'展开'}详情 · /logs 回看 · Ctrl+C 退出`):this.editor&&this.multilineInput?'↑↓ 移动 · Tab 补全 · Esc 返回 · Ctrl+C 退出':(width<45?'↑↓ 选择 Enter 确认 Esc 返回':'↑↓ 选择 Enter 确认 Tab 补全 Esc 返回 Ctrl+C 退出'));
91
+ return lines.map(line=>line?fitLine(' '+palette.muted(line),width):'');
92
+ };
80
93
  const renderTranscript=this.transcript.render.bind(this.transcript);
81
94
  this.headingLines=(width,mark='FISHtools')=>{
82
95
  const room=Math.max(1,width-2),lines=[''];
@@ -127,7 +140,7 @@ export class TerminalView{
127
140
  browseCommands(data){
128
141
  const direction=matchesKey(data,'up')?-1:matchesKey(data,'down')?1:0;
129
142
  // 补全菜单保留方向键;多行编辑保留光标移动,不把配置值放进命令历史。
130
- if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getText().includes('\n')||!this.commandHistory.length)return false;
143
+ if(!direction||!this.editor||this.editor.isShowingAutocomplete()||this.editor.getExpandedText().includes('\n')||!this.commandHistory.length)return false;
131
144
  const current=this.commandHistoryIndex??this.commandHistory.length;
132
145
  const next=Math.max(0,Math.min(this.commandHistory.length,current+direction));
133
146
  if(next===current)return true;
@@ -235,19 +248,40 @@ export class TerminalView{
235
248
  if(message.startsWith('fst >'))title.render=()=>[];
236
249
  this.prompt.clear();this.prompt.addChild(title);this.prompt.addChild(component);this.tui.setFocus(component);this.tui.requestRender();
237
250
  try{return await new Promise((resolve,reject)=>{this.pending={resolve,reject};component.onSubmit=text=>{
238
- if(this.homeInput&&text.trim()&&this.commandHistory.at(-1)!==text.trim()){
239
- this.commandHistory.push(text.trim());if(this.commandHistory.length>100)this.commandHistory.shift();
251
+ // Shell 历史保留末尾换行,避免再次执行时改变反斜杠续行等语义。
252
+ const historyText=text.trimStart().startsWith('!')?text:text.trim();
253
+ if(this.homeInput&&historyText&&this.commandHistory.at(-1)!==historyText){
254
+ this.commandHistory.push(historyText);if(this.commandHistory.length>100)this.commandHistory.shift();
240
255
  }
241
256
  resolve(text);
242
257
  };component.onSelect=item=>resolve(item.value);component.onCancel=()=>reject(cancelled());});}
243
- finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.prompt.clear();this.tui.requestRender();}
258
+ finally{if(this.homeInput)this.terminal.write(mouseOff);this.pending=null;this.editor=null;this.homeInput=false;this.multilineInput=false;this.prompt.clear();this.tui.requestRender();}
244
259
  }
245
- input(message,{value='',commands=[],files=true,workflows=[]}={}){
260
+ input(message,{value='',commands=[],files=true,workflows=[],multiline=false}={}){
246
261
  const editor=new Editor(this.tui,{borderColor:palette.border,selectList:theme},{paddingX:1});
262
+ const handleInput=editor.handleInput.bind(editor);
263
+ editor.handleInput=data=>{
264
+ // ProcessTerminal 已把分段粘贴合并为完整事件;公开插入接口保留脚本制表符和换行。
265
+ if(data.startsWith('\x1b[200~')&&data.endsWith('\x1b[201~')){
266
+ const text=safe(data.slice(6,-6).replace(/\r\n?/g,'\n'));
267
+ if(this.homeInput||multiline||text.includes('\n')){editor.insertTextAtCursor(text);return;}
268
+ }
269
+ if(matchesKey(data,'shift+enter')||matchesKey(data,'alt+enter')||data==='\x1b[13;2~'){
270
+ editor.insertTextAtCursor('\n');return;
271
+ }
272
+ // 反斜杠属于命令原文,不能触发上游的“反斜杠后 Enter 换行”兼容键位。
273
+ if(data==='\\'){editor.insertTextAtCursor(data);return;}
274
+ if(matchesKey(data,'enter')&&!editor.isShowingAutocomplete()){
275
+ if(!editor.disableSubmit){const text=editor.getExpandedText();editor.setText('');editor.onSubmit?.(text);}
276
+ return;
277
+ }
278
+ handleInput(data);
279
+ };
247
280
  const renderEditor=editor.render.bind(editor);
248
281
  editor.render=width=>{
249
- if(width<8)return renderEditor(width);
250
- const inner=width-4,lines=renderEditor(inner);let closed=false;
282
+ // 上游 visibleWidth 按三个空格计算 Tab;只扩展显示,提交仍保留原始 Tab。
283
+ if(width<8)return renderEditor(width).map(line=>line.replaceAll('\t',' '));
284
+ const inner=width-4,lines=renderEditor(inner).map(line=>line.replaceAll('\t',' '));let closed=false;
251
285
  return lines.map((line,i)=>{
252
286
  if(i===0)return ' '+palette.border('╭'+'─'.repeat(inner)+'╮');
253
287
  if(!closed&&/^─+$/.test(safe(line))){closed=true;return ' '+palette.border('╰'+'─'.repeat(inner)+'╯');}
@@ -256,6 +290,7 @@ export class TerminalView{
256
290
  });
257
291
  };
258
292
  this.homeInput=message.startsWith('fst >');
293
+ this.multilineInput=multiline;
259
294
  this.commandHistoryIndex=null;
260
295
  if(this.homeInput)editor.onChange=text=>{
261
296
  if(this.commandHistoryIndex!==null&&text!==this.commandHistory[this.commandHistoryIndex])this.commandHistoryIndex=null;
@@ -4,7 +4,6 @@ export async function workflowMenu(session){
4
4
  const workflows=await app.listWorkflows();
5
5
  const choice=await ui.select('工作流 · 选择后编辑或执行',[
6
6
  ...workflows.map(flow=>({value:`workflow:${flow.id}`,label:flow.title??flow.id,description:`${flow.id} · ${flow.steps.length} 步`})),
7
- ...(!app.project.readOnly?workflows.map(flow=>({value:`duplicate:${flow.id}`,label:`复制 ${flow.title??flow.id} 为新工作流`,description:`从已保存版本 ${flow.id} 创建副本` })):[]),
8
7
  ...(!app.project.readOnly?[{value:'new',label:'+ 新建工作流'}]:[]),
9
8
  ...(session.dirty?[{value:'draft',label:`继续编辑草稿:${session.flow.title}`}]:[]),
10
9
  {value:'back',label:'返回主面板'},
@@ -12,11 +11,7 @@ export async function workflowMenu(session){
12
11
  if(choice==='back')return;
13
12
  try{
14
13
  const id=choice.startsWith('workflow:')?choice.slice(9):null;
15
- if(choice.startsWith('duplicate:')){
16
- if(!await session.discard())continue;
17
- await duplicateIntoSession(session,workflows.find(flow=>flow.id===choice.slice(10)));
18
- }
19
- else if(choice==='new'){if(await session.execute('/new')===false)continue;}
14
+ if(choice==='new'){if(await session.execute('/new')===false)continue;}
20
15
  else if(choice!=='draft'&&session.flow?.id!==id){
21
16
  if(!await session.discard())continue;
22
17
  session.flow=structuredClone(workflows.find(flow=>flow.id===id));session.dirty=false;
@@ -3,6 +3,7 @@ import {fileURLToPath} from 'node:url';
3
3
  import {access,readFile,readdir,lstat,mkdir,copyFile,realpath} from 'node:fs/promises';
4
4
 
5
5
  const topics=[
6
+ ['commands','系统命令与快捷名称','docs/terminal-commands.md'],
6
7
  ['plugins','插件开发规范','docs/plugin-development.md'],
7
8
  ['deployment','安装与部署','docs/deployment.md'],
8
9
  ['control','控制流与运行观察规范','docs/superpowers/specs/2026-10-11-control-observation.md'],
@@ -14,7 +15,7 @@ const topics=[
14
15
  ['readme','Fishtools 使用说明','README.md'],
15
16
  ];
16
17
  // 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
17
- const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
18
+ const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','plugins/commands','plugins/web/README.md','packages/sdk','packages/core','packages/pack','packages/fst-logs','packages/fst-platform','packages/fst-workflow','packages/fst-plugin','packages/fst-workspace','packages/fst-devtools','packages/fst-cli','tests/application.test.js'];
18
19
  const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
19
20
  const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
20
21
 
@@ -13,6 +13,8 @@
13
13
  | --- | --- |
14
14
  | `@mytools/fst-logs` | `./src/run-logs.js` |
15
15
  | `@mytools/fst-logs/markdown` | `./src/markdown-log.js` |
16
+ | `@mytools/fst-logs/steps` | `./src/step-summaries.js` |
17
+ | `@mytools/fst-logs/preview` | `./src/run-preview.js` |
16
18
  | `@mytools/fst-logs/package.json` | `./package.json` |
17
19
 
18
20
  仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
@@ -35,6 +37,15 @@
35
37
 
36
38
  快照与历史元数据另有 `stepSummaries`、`stepSummaryBytes`、`stepsTruncated`。步骤摘要按 callPath(缺少时按 stepId)保留最新状态,仅包含类型、定位、动作、时间、序号、耗时及有界原因/错误,不缓存 input/output。步骤摘要使用独立的 maxEvents/maxMemoryBytes 预算,因此两份缓存合计最多为两倍 maxMemoryBytes;单条步骤摘要仍受 maxEventBytes 限制。普通日志淘汰不影响步骤状态;步骤本身超预算淘汰时 stepsTruncated 为 true,展示端应把无法确认的步骤显示为未知状态。
37
39
 
40
+ `createStepSummaries(policy)` 从 `/steps` 入口提供同一套摘要缓存,日志写入器与 TUI 共用。
41
+ `append(event)` 更新状态并刷新淘汰顺序;`get(callPath)`、`values()` 和 `snapshot()` 返回副本。
42
+ `snapshot()` 包含上述三个步骤摘要字段,`size` 为保留条数。
43
+
44
+ `previewRun(record,{maxValueBytes=65536})` 从 `/preview` 入口提供展示与历史元数据共用的结果投影。
45
+ input/output、error、reason 等字段转为有界 JSON 预览,过大时标记 `<字段>Truncated:true`;业务数据对象不变。
46
+ 定位字段与 events/stepSummaries 保留,后两者由各自缓存预算负责;展示端应裁去不使用的缓存副本。
47
+ maxValueBytes 至少为 2,连预览本身也容不下时省略字段值并保留截断标记。
48
+
38
49
  队列预算在追加时检查。未等待 append 的插件同样受到预算约束;超限返回 `LOG_QUEUE_LIMIT` 拒绝,并使 finish 失败。拒绝已安装内部处理,调用方仍可 await 观察失败,不会产生未处理拒绝。`LOG_DISK_LIMIT` 表示可选磁盘预算不足,拒绝完整追加而不静默截断。磁盘预算与队列预算都计算 JSONL 和 Markdown。建议插件等待 append,以便磁盘写入形成背压。
39
50
 
40
51
  `read`/`list` 仅读取受限的 result.json 元数据,运行中的 read 回退到归属元数据;不加载 JSONL 或 Markdown 正文。巨大旧 result.json 超过读取预算时,read 明确返回 `LOG_METADATA_LIMIT`,list 跳过该记录。旧版本缺少归属信息时仅在 `allowLegacy:true` 下可读。
@@ -6,6 +6,8 @@
6
6
  "main": "src/run-logs.js",
7
7
  "exports": {
8
8
  ".": "./src/run-logs.js",
9
+ "./steps": "./src/step-summaries.js",
10
+ "./preview": "./src/run-preview.js",
9
11
  "./markdown": "./src/markdown-log.js",
10
12
  "./package.json": "./package.json"
11
13
  },