@code4bug/jarvis-agent 1.3.6 → 1.3.7

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/README.md CHANGED
@@ -131,6 +131,7 @@ jarvis --version
131
131
  | `/new` | 开启新会话 |
132
132
  | `/resume` | 恢复历史会话 |
133
133
  | `/resume <ID>` | 直接恢复指定历史会话 |
134
+ | `/rewind` | 打开当前会话提问列表并回退到指定位置 |
134
135
  | `/agent` | 打开智能体切换列表 |
135
136
  | `/agent <名称>` | 切换智能体,重启后生效 |
136
137
  | `/permissions` | 查看持久化授权 |
@@ -166,6 +167,8 @@ jarvis --version
166
167
  - 斜杠菜单中可使用 `↑ / ↓` 切换命令
167
168
  - 在斜杠菜单中按 `Tab` 可补全当前命令
168
169
  - 在斜杠菜单中按 `Enter` 可提交当前命令
170
+ - 输入 `/rewind` 后可列出当前会话的提问列表,按时间从上到下升序排列
171
+ - 在 `/rewind` 列表中选择某条提问后,会把该提问回填到输入框,并丢弃该位置之后的上下文
169
172
  - 对于多行输入,`↑ / ↓` 用于在输入框内移动光标
170
173
  - 对于单行输入,`↑ / ↓` 用于切换历史输入
171
174
 
@@ -7,6 +7,8 @@
7
7
  export interface SlashCommand {
8
8
  /** 命令名称(不含 /) */
9
9
  name: string;
10
+ /** 可选:菜单展示名 */
11
+ displayName?: string;
10
12
  /** 简短描述 */
11
13
  description: string;
12
14
  /** 命令类别 */
@@ -12,6 +12,7 @@ const builtinCommands = [
12
12
  { name: 'quit', description: '退出应用程序', category: 'builtin', submitMode: 'action' },
13
13
  { name: 'bye', description: '退出应用程序', category: 'builtin', submitMode: 'action' },
14
14
  { name: 'resume', description: '恢复历史会话上下文', category: 'builtin', submitMode: 'list' },
15
+ { name: 'rewind', description: '回退当前会话到指定提问位置', category: 'builtin', submitMode: 'list' },
15
16
  { name: 'help', description: '显示帮助信息', category: 'builtin', submitMode: 'action' },
16
17
  { name: 'agent', description: '切换智能体', category: 'builtin', submitMode: 'list' },
17
18
  { name: 'permissions', description: '查看所有持久化授权列表', category: 'builtin', submitMode: 'action' },
@@ -37,7 +37,8 @@ function SlashCommandMenu({ commands, selectedIndex, maxVisible = 6, }) {
37
37
  const isSelected = realIndex === selectedIndex;
38
38
  const catColor = categoryColor[cmd.category] ?? 'gray';
39
39
  const catText = categoryLabel[cmd.category] ?? cmd.category;
40
- return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'white', children: [' ', "/", cmd.name, ' '] }), _jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: ["- ", cmd.description, ' '] }), _jsxs(Text, { color: catColor, dimColor: true, children: ["[", catText, "]"] })] }, cmd.name));
40
+ const displayName = cmd.displayName ?? cmd.name;
41
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'white', children: [' ', "/", displayName, ' '] }), _jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: ["- ", cmd.description, ' '] }), _jsxs(Text, { color: catColor, dimColor: true, children: ["[", catText, "]"] })] }, `${cmd.name}-${realIndex}`));
41
42
  }), start + visible < total && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u2193 \u8FD8\u6709 ", total - start - visible, " \u9879"] }))] }));
42
43
  }
43
44
  export default React.memo(SlashCommandMenu);
@@ -30,6 +30,9 @@ export declare class QueryEngine {
30
30
  /** 注册持久 UI 回调,供后台 spawn_agent 子 Agent 推送消息 */
31
31
  registerUIBus(onMessage: (msg: Message) => void, onUpdateMessage: (id: string, updates: Partial<Message>) => void): void;
32
32
  private createSession;
33
+ private rebuildTranscriptFromMessages;
34
+ private recomputeSessionSummary;
35
+ private recomputeSessionTokens;
33
36
  private ensureSessionDir;
34
37
  private createService;
35
38
  /** 处理用户输入(在独立 Worker 线程中执行) */
@@ -62,6 +65,19 @@ export declare class QueryEngine {
62
65
  messages: Message[];
63
66
  } | null;
64
67
  getSession(): Session;
68
+ getCurrentSessionUserTurns(): Array<{
69
+ messageId: string;
70
+ turnIndex: number;
71
+ input: string;
72
+ answerPreview: string;
73
+ timestamp: number;
74
+ }>;
75
+ rewindToUserMessage(messageId: string): {
76
+ session: Session;
77
+ messages: Message[];
78
+ input: string;
79
+ turnIndex: number;
80
+ } | null;
65
81
  /**
66
82
  * 清理非当前会话的所有历史会话文件
67
83
  * @returns 删除的会话数量
@@ -51,6 +51,39 @@ export class QueryEngine {
51
51
  totalCost: 0,
52
52
  };
53
53
  }
54
+ rebuildTranscriptFromMessages(messages) {
55
+ const transcript = [];
56
+ for (const msg of messages) {
57
+ if (msg.type === 'user') {
58
+ transcript.push({ role: 'user', content: msg.content });
59
+ }
60
+ else if (msg.type === 'reasoning' && msg.status === 'success' && msg.content) {
61
+ transcript.push({
62
+ role: 'assistant',
63
+ content: [{ type: 'text', text: msg.content }],
64
+ });
65
+ }
66
+ else if (msg.type === 'tool_exec' && msg.toolName && msg.toolResult !== undefined) {
67
+ transcript.push({
68
+ role: 'assistant',
69
+ content: [{ type: 'tool_use', id: msg.id, name: msg.toolName, input: msg.toolArgs ?? {} }],
70
+ });
71
+ transcript.push({
72
+ role: 'tool_result',
73
+ content: msg.toolResult,
74
+ toolUseId: msg.id,
75
+ });
76
+ }
77
+ }
78
+ return transcript;
79
+ }
80
+ recomputeSessionSummary(messages) {
81
+ const firstUser = messages.find((m) => m.type === 'user');
82
+ return firstUser ? firstUser.content.slice(0, 80).replace(/\n/g, ' ') : undefined;
83
+ }
84
+ recomputeSessionTokens(messages) {
85
+ return messages.reduce((sum, msg) => sum + (msg.tokenCount ?? 0), 0);
86
+ }
54
87
  ensureSessionDir() {
55
88
  if (!fs.existsSync(SESSIONS_DIR)) {
56
89
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
@@ -321,31 +354,7 @@ export class QueryEngine {
321
354
  const cleanedMessages = loaded.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
322
355
  this.session.messages = cleanedMessages;
323
356
  // 从历史消息重建 transcript
324
- this.transcript = [];
325
- for (const msg of loaded.messages) {
326
- if (msg.type === 'user') {
327
- this.transcript.push({ role: 'user', content: msg.content });
328
- }
329
- else if (msg.type === 'reasoning' && msg.status === 'success' && msg.content) {
330
- // assistant 消息的 content 必须是 ContentBlock[],与 query.ts 中的构建方式一致
331
- this.transcript.push({
332
- role: 'assistant',
333
- content: [{ type: 'text', text: msg.content }],
334
- });
335
- }
336
- else if (msg.type === 'tool_exec' && msg.toolName && msg.toolResult !== undefined) {
337
- // 工具调用:assistant tool_use + tool_result
338
- this.transcript.push({
339
- role: 'assistant',
340
- content: [{ type: 'tool_use', id: msg.id, name: msg.toolName, input: msg.toolArgs ?? {} }],
341
- });
342
- this.transcript.push({
343
- role: 'tool_result',
344
- content: msg.toolResult,
345
- toolUseId: msg.id,
346
- });
347
- }
348
- }
357
+ this.transcript = this.rebuildTranscriptFromMessages(cleanedMessages);
349
358
  logInfo('session.loaded', {
350
359
  sessionId,
351
360
  messageCount: cleanedMessages.length,
@@ -362,6 +371,57 @@ export class QueryEngine {
362
371
  getSession() {
363
372
  return this.session;
364
373
  }
374
+ getCurrentSessionUserTurns() {
375
+ const cleanedMessages = this.session.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
376
+ const userMessages = cleanedMessages.filter((m) => m.type === 'user');
377
+ return userMessages.map((msg, index) => {
378
+ const currentIndex = cleanedMessages.findIndex((item) => item.id === msg.id);
379
+ const nextUserIndex = cleanedMessages.findIndex((item, i) => i > currentIndex && item.type === 'user');
380
+ const endIndex = nextUserIndex >= 0 ? nextUserIndex : cleanedMessages.length;
381
+ const answerPreview = cleanedMessages
382
+ .slice(currentIndex + 1, endIndex)
383
+ .filter((item) => item.type === 'reasoning' && item.content)
384
+ .map((item) => item.content.trim())
385
+ .find(Boolean) ?? '';
386
+ return {
387
+ messageId: msg.id,
388
+ turnIndex: index + 1,
389
+ input: msg.content,
390
+ answerPreview,
391
+ timestamp: msg.timestamp,
392
+ };
393
+ });
394
+ }
395
+ rewindToUserMessage(messageId) {
396
+ const cleanedMessages = this.session.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
397
+ const targetIndex = cleanedMessages.findIndex((m) => m.id === messageId && m.type === 'user');
398
+ if (targetIndex < 0)
399
+ return null;
400
+ const userTurns = cleanedMessages.filter((m) => m.type === 'user');
401
+ const turnIndex = userTurns.findIndex((m) => m.id === messageId) + 1;
402
+ const targetMessage = cleanedMessages[targetIndex];
403
+ const keptMessages = cleanedMessages.slice(0, targetIndex);
404
+ this.session.messages = keptMessages;
405
+ this.session.summary = this.recomputeSessionSummary(keptMessages);
406
+ this.session.totalTokens = this.recomputeSessionTokens(keptMessages);
407
+ this.session.updatedAt = Date.now();
408
+ this.transcript = this.rebuildTranscriptFromMessages(keptMessages);
409
+ this.touchActivity();
410
+ this.saveSession();
411
+ logInfo('session.rewind', {
412
+ sessionId: this.session.id,
413
+ targetMessageId: messageId,
414
+ turnIndex,
415
+ keptMessageCount: keptMessages.length,
416
+ transcriptLength: this.transcript.length,
417
+ });
418
+ return {
419
+ session: this.session,
420
+ messages: keptMessages,
421
+ input: targetMessage.content,
422
+ turnIndex,
423
+ };
424
+ }
365
425
  /**
366
426
  * 清理非当前会话的所有历史会话文件
367
427
  * @returns 删除的会话数量
@@ -27,12 +27,13 @@ export declare function useSlashMenu(opts: UseSlashMenuOptions): {
27
27
  slashMenuIndex: number;
28
28
  agentMenuMode: boolean;
29
29
  resumeMenuMode: boolean;
30
+ rewindMenuMode: boolean;
30
31
  handleSlashMenuUp: () => void;
31
32
  handleSlashMenuDown: () => void;
32
33
  handleSlashMenuClose: () => void;
33
34
  getSelectedCommand: () => SlashCommand | null;
34
35
  autocompleteSlashMenuSelection: (currentInput: string) => string;
35
- openListCommand: (commandName: "agent" | "resume") => "/agent " | "/resume ";
36
+ openListCommand: (commandName: "agent" | "resume" | "rewind") => "/agent " | "/rewind " | "/resume ";
36
37
  updateSlashMenu: (val: string) => void;
37
38
  setSlashMenuVisible: import("react").Dispatch<import("react").SetStateAction<boolean>>;
38
39
  resumeSession: (sessionId: string) => void;
@@ -13,6 +13,7 @@ export function useSlashMenu(opts) {
13
13
  const [slashMenuIndex, setSlashMenuIndex] = useState(0);
14
14
  const [agentMenuMode, setAgentMenuMode] = useState(false);
15
15
  const [resumeMenuMode, setResumeMenuMode] = useState(false);
16
+ const [rewindMenuMode, setRewindMenuMode] = useState(false);
16
17
  // 上移
17
18
  const handleSlashMenuUp = useCallback(() => {
18
19
  setSlashMenuIndex((prev) => (prev > 0 ? prev - 1 : slashMenuItems.length - 1));
@@ -23,9 +24,10 @@ export function useSlashMenu(opts) {
23
24
  }, [slashMenuItems.length]);
24
25
  // 关闭
25
26
  const handleSlashMenuClose = useCallback(() => {
26
- if (agentMenuMode || resumeMenuMode) {
27
+ if (agentMenuMode || resumeMenuMode || rewindMenuMode) {
27
28
  setAgentMenuMode(false);
28
29
  setResumeMenuMode(false);
30
+ setRewindMenuMode(false);
29
31
  setInput('/');
30
32
  const matched = filterCommands('');
31
33
  setSlashMenuItems(matched);
@@ -35,7 +37,7 @@ export function useSlashMenu(opts) {
35
37
  else {
36
38
  setSlashMenuVisible(false);
37
39
  }
38
- }, [agentMenuMode, resumeMenuMode, setInput]);
40
+ }, [agentMenuMode, resumeMenuMode, rewindMenuMode, setInput]);
39
41
  // 恢复会话的通用逻辑
40
42
  const resumeSession = useCallback((sessionId) => {
41
43
  if (!engineRef.current)
@@ -76,6 +78,25 @@ export function useSlashMenu(opts) {
76
78
  return null;
77
79
  return slashMenuItems[slashMenuIndex] ?? null;
78
80
  }, [slashMenuItems, slashMenuIndex]);
81
+ const buildRewindItems = useCallback(() => {
82
+ if (!engineRef.current)
83
+ return [];
84
+ return engineRef.current.getCurrentSessionUserTurns()
85
+ .slice()
86
+ .map((turn) => {
87
+ const date = new Date(turn.timestamp);
88
+ const dateStr = `${date.getMonth() + 1}/${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
89
+ const question = turn.input.replace(/\s+/g, ' ').slice(0, 32);
90
+ const answer = turn.answerPreview.replace(/\s+/g, ' ').slice(0, 24);
91
+ return {
92
+ name: turn.messageId,
93
+ displayName: `rewind-${turn.turnIndex}`,
94
+ description: `[Q${turn.turnIndex} ${dateStr}] ${question}${answer ? ` | A: ${answer}` : ''}`,
95
+ category: 'builtin',
96
+ submitMode: 'action',
97
+ };
98
+ });
99
+ }, [engineRef]);
79
100
  const openListCommand = useCallback((commandName) => {
80
101
  if (commandName === 'agent') {
81
102
  setInput('/agent ');
@@ -84,9 +105,21 @@ export function useSlashMenu(opts) {
84
105
  setSlashMenuIndex(0);
85
106
  setAgentMenuMode(true);
86
107
  setResumeMenuMode(false);
108
+ setRewindMenuMode(false);
87
109
  setSlashMenuVisible(matched.length > 0);
88
110
  return '/agent ';
89
111
  }
112
+ if (commandName === 'rewind') {
113
+ const items = buildRewindItems();
114
+ setInput('/rewind ');
115
+ setSlashMenuItems(items);
116
+ setSlashMenuIndex(0);
117
+ setSlashMenuVisible(items.length > 0);
118
+ setRewindMenuMode(true);
119
+ setResumeMenuMode(false);
120
+ setAgentMenuMode(false);
121
+ return '/rewind ';
122
+ }
90
123
  const sessions = QueryEngine.listSessions().slice(0, 20);
91
124
  const items = sessions.map((s) => {
92
125
  const date = new Date(s.updatedAt);
@@ -104,8 +137,9 @@ export function useSlashMenu(opts) {
104
137
  setSlashMenuVisible(items.length > 0);
105
138
  setResumeMenuMode(true);
106
139
  setAgentMenuMode(false);
140
+ setRewindMenuMode(false);
107
141
  return '/resume ';
108
- }, [setInput]);
142
+ }, [setInput, buildRewindItems]);
109
143
  const autocompleteSlashMenuSelection = useCallback((currentInput) => {
110
144
  const cmd = getSelectedCommand();
111
145
  if (!cmd)
@@ -124,17 +158,23 @@ export function useSlashMenu(opts) {
124
158
  setSlashMenuVisible(false);
125
159
  return nextInput;
126
160
  }
161
+ if (rewindMenuMode) {
162
+ const nextInput = `/rewind ${cmd.name}`;
163
+ setInput(nextInput);
164
+ setSlashMenuVisible(false);
165
+ return nextInput;
166
+ }
127
167
  const match = currentInput.match(/^\/\S*/);
128
168
  const suffix = match ? currentInput.slice(match[0].length) : '';
129
169
  const needsTrailingSpace = !suffix && (cmd.submitMode === 'context' || cmd.submitMode === 'list');
130
170
  const nextInput = `/${cmd.name}${suffix}${needsTrailingSpace ? ' ' : ''}`;
131
171
  setInput(nextInput);
132
- if (cmd.submitMode === 'list' && (cmd.name === 'agent' || cmd.name === 'resume') && !suffix.trim()) {
172
+ if (cmd.submitMode === 'list' && (cmd.name === 'agent' || cmd.name === 'resume' || cmd.name === 'rewind') && !suffix.trim()) {
133
173
  return openListCommand(cmd.name);
134
174
  }
135
175
  updateSlashMenu(nextInput);
136
176
  return nextInput;
137
- }, [agentMenuMode, resumeMenuMode, getSelectedCommand, setInput, openListCommand]);
177
+ }, [agentMenuMode, resumeMenuMode, rewindMenuMode, getSelectedCommand, setInput, openListCommand]);
138
178
  // 输入变化时更新菜单
139
179
  const updateSlashMenu = useCallback((val) => {
140
180
  if (val.startsWith('/') && !val.includes('\n')) {
@@ -172,6 +212,22 @@ export function useSlashMenu(opts) {
172
212
  setSlashMenuVisible(matched.length > 0);
173
213
  setResumeMenuMode(true);
174
214
  setAgentMenuMode(false);
215
+ setRewindMenuMode(false);
216
+ return;
217
+ }
218
+ // /rewind 二级菜单
219
+ if (/^rewind\s/i.test(query)) {
220
+ const subQuery = query.replace(/^rewind\s*/i, '').toLowerCase();
221
+ const items = buildRewindItems();
222
+ const matched = subQuery
223
+ ? items.filter((i) => i.name.includes(subQuery) || (i.displayName ?? '').toLowerCase().includes(subQuery) || i.description.toLowerCase().includes(subQuery))
224
+ : items;
225
+ setSlashMenuItems(matched);
226
+ setSlashMenuIndex(0);
227
+ setSlashMenuVisible(matched.length > 0);
228
+ setRewindMenuMode(true);
229
+ setResumeMenuMode(false);
230
+ setAgentMenuMode(false);
175
231
  return;
176
232
  }
177
233
  // 一级菜单
@@ -181,19 +237,22 @@ export function useSlashMenu(opts) {
181
237
  setSlashMenuVisible(matched.length > 0);
182
238
  setAgentMenuMode(false);
183
239
  setResumeMenuMode(false);
240
+ setRewindMenuMode(false);
184
241
  }
185
242
  else {
186
243
  setSlashMenuVisible(false);
187
244
  setAgentMenuMode(false);
188
245
  setResumeMenuMode(false);
246
+ setRewindMenuMode(false);
189
247
  }
190
- }, []);
248
+ }, [buildRewindItems]);
191
249
  return {
192
250
  slashMenuVisible,
193
251
  slashMenuItems,
194
252
  slashMenuIndex,
195
253
  agentMenuMode,
196
254
  resumeMenuMode,
255
+ rewindMenuMode,
197
256
  handleSlashMenuUp,
198
257
  handleSlashMenuDown,
199
258
  handleSlashMenuClose,
@@ -260,6 +260,49 @@ export default function REPL() {
260
260
  }
261
261
  return;
262
262
  }
263
+ // /rewind
264
+ if (cmdName === 'rewind') {
265
+ if (hasArgs && engineRef.current) {
266
+ setInput('');
267
+ slashMenu.setSlashMenuVisible(false);
268
+ const messageId = parts.slice(1).join(' ').trim();
269
+ const result = engineRef.current.rewindToUserMessage(messageId);
270
+ if (!result) {
271
+ const errMsg = {
272
+ id: `rewind-err-${Date.now()}`,
273
+ type: 'error',
274
+ status: 'error',
275
+ content: '未找到要回退的会话位置',
276
+ timestamp: Date.now(),
277
+ };
278
+ setMessages((prev) => [...prev, errMsg]);
279
+ return;
280
+ }
281
+ stopAll();
282
+ setMessages([
283
+ ...result.messages,
284
+ {
285
+ id: `rewind-${Date.now()}`,
286
+ type: 'system',
287
+ status: 'success',
288
+ content: `已回退到当前会话的第 ${result.turnIndex} 条提问,请确认后重新发送。`,
289
+ timestamp: Date.now(),
290
+ },
291
+ ]);
292
+ sessionRef.current = result.session;
293
+ tokenCountRef.current = result.session.totalTokens;
294
+ syncTokenDisplay(result.session.totalTokens);
295
+ setLoopState(null);
296
+ setIsProcessing(false);
297
+ setShowWelcome(false);
298
+ resetNavigation();
299
+ setInput(result.input);
300
+ }
301
+ else {
302
+ slashMenu.openListCommand('rewind');
303
+ }
304
+ return;
305
+ }
263
306
  // /create_skill
264
307
  if (cmdName === 'create_skill') {
265
308
  setInput('');
@@ -31,6 +31,7 @@ export async function executeSlashCommand(cmdName) {
31
31
  ' /bye 退出应用程序',
32
32
  ' /resume 恢复历史会话(支持二级菜单选择)',
33
33
  ' /resume <ID> 直接恢复指定会话',
34
+ ' /rewind 回退当前会话到指定提问位置',
34
35
  ' /help 显示此帮助信息',
35
36
  ' /session_clear 清理所有非当前会话的历史记录',
36
37
  ' /skills 查看当前所有 tools 和 skills',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code4bug/jarvis-agent",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "基于 React + TypeScript + Ink 构建的命令行智能体交互界面",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",