pikiclaw 0.2.53 → 0.2.55

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # pikiclaw
4
4
 
5
- **Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via best IM.**
5
+ **Put the world's smartest AI agents in your pocket. Command local Claude, Codex & Gemini via best IM.**
6
6
 
7
7
  *让最好用的 IM 变成你电脑上的顶级 Agent 控制台*
8
8
 
@@ -199,8 +199,10 @@ export function buildHumanLoopPromptMarkdown(prompt) {
199
199
  // ---------------------------------------------------------------------------
200
200
  // LivePreview renderer — produces Markdown for Feishu card elements
201
201
  // ---------------------------------------------------------------------------
202
- export function buildInitialPreviewMarkdown(agent, model, effort) {
202
+ export function buildInitialPreviewMarkdown(agent, model, effort, waiting = false) {
203
203
  const parts = [];
204
+ if (waiting)
205
+ parts.push('Waiting in queue...');
204
206
  if (model)
205
207
  parts.push(model);
206
208
  else
@@ -28,6 +28,7 @@ const SHUTDOWN_EXIT_CODE = {
28
28
  SIGINT: 130,
29
29
  SIGTERM: 143,
30
30
  };
31
+ const FEISHU_FILE_STAGE_REACTION = 'Get';
31
32
  function describeError(err) {
32
33
  if (!(err instanceof Error))
33
34
  return String(err ?? 'unknown error');
@@ -464,6 +465,17 @@ export class FeishuBot extends Bot {
464
465
  return active.result;
465
466
  };
466
467
  }
468
+ async safeSetMessageReaction(chatId, messageId, reactions) {
469
+ if (!supportsChannelCapability(this.channel, 'messageReactions'))
470
+ return;
471
+ const setReaction = this.channel?.setMessageReaction;
472
+ if (typeof setReaction !== 'function')
473
+ return;
474
+ try {
475
+ await setReaction.call(this.channel, chatId, messageId, reactions);
476
+ }
477
+ catch { }
478
+ }
467
479
  // ---- streaming bridge -----------------------------------------------------
468
480
  async handleMessage(msg, ctx) {
469
481
  const text = msg.text.trim();
@@ -507,6 +519,7 @@ export class FeishuBot extends Bot {
507
519
  throw new Error('no files persisted');
508
520
  this.log(`[handleMessage] staged files chat=${ctx.chatId} session=${staged.sessionId} files=${staged.importedFiles.length}`);
509
521
  this.registerSessionMessage(ctx.chatId, ctx.messageId, session);
522
+ await this.safeSetMessageReaction(ctx.chatId, ctx.messageId, [FEISHU_FILE_STAGE_REACTION]);
510
523
  }
511
524
  catch (e) {
512
525
  this.log(`[handleMessage] stage files failed: ${e?.message || e}`);
@@ -525,6 +538,7 @@ export class FeishuBot extends Bot {
525
538
  const start = Date.now();
526
539
  this.log(`[handleMessage] start chat=${ctx.chatId} agent=${session.agent} session=${session.sessionId || '(new)'} ` +
527
540
  `files=${files.length} prompt="${prompt.slice(0, 100)}"`);
541
+ const waiting = this.sessionHasPendingWork(session);
528
542
  const taskId = this.createTaskId(session);
529
543
  this.beginTask({
530
544
  taskId,
@@ -538,7 +552,7 @@ export class FeishuBot extends Bot {
538
552
  const stopKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
539
553
  const model = session.modelId || this.modelForAgent(session.agent);
540
554
  const effort = this.effortForAgent(session.agent);
541
- const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort), {
555
+ const placeholderId = await this.channel.sendStreamingCard(ctx.chatId, buildInitialPreviewMarkdown(session.agent, model, effort, waiting), {
542
556
  replyTo: ctx.messageId || undefined,
543
557
  keyboard: stopKeyboard,
544
558
  });
@@ -327,8 +327,10 @@ function trimActivityForPreview(text, maxChars = 900) {
327
327
  return text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + '...';
328
328
  return [...head, '...', ...tail].join('\n');
329
329
  }
330
- export function buildInitialPreviewHtml(agent) {
331
- return formatPreviewFooterHtml(agent, 0);
330
+ export function buildInitialPreviewHtml(agent, waiting = false) {
331
+ return waiting
332
+ ? `<i>Waiting in queue...</i>\n\n${formatPreviewFooterHtml(agent, 0)}`
333
+ : formatPreviewFooterHtml(agent, 0);
332
334
  }
333
335
  export function buildStreamPreviewHtml(input) {
334
336
  const maxBody = 2400;
@@ -502,9 +502,10 @@ export class TelegramBot extends Bot {
502
502
  sourceMessageId: ctx.messageId,
503
503
  });
504
504
  const stopKeyboard = this.buildStopKeyboard(this.actionIdForTask(taskId));
505
+ const waiting = this.sessionHasPendingWork(session);
505
506
  let phId = null;
506
507
  if (canEditMessages) {
507
- const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent), { parseMode: 'HTML', messageThreadId, keyboard: stopKeyboard });
508
+ const placeholderId = await ctx.reply(buildInitialPreviewHtml(session.agent, waiting), { parseMode: 'HTML', messageThreadId, keyboard: stopKeyboard });
508
509
  phId = typeof placeholderId === 'number' ? placeholderId : null;
509
510
  if (phId != null) {
510
511
  this.registerSessionMessage(ctx.chatId, phId, session);
@@ -217,7 +217,7 @@ class FeishuChannel extends Channel {
217
217
  typingIndicators: false,
218
218
  commandMenu: true,
219
219
  callbackActions: true,
220
- messageReactions: false,
220
+ messageReactions: true,
221
221
  fileUpload: true,
222
222
  fileDownload: true,
223
223
  threads: false,
@@ -710,6 +710,19 @@ class FeishuChannel extends Channel {
710
710
  async sendTyping(_chatId) {
711
711
  // Feishu has no typing indicator API — no-op
712
712
  }
713
+ async setMessageReaction(_chatId, msgId, reactions) {
714
+ const messageId = String(msgId || '').trim();
715
+ const emojiTypes = [...new Set(reactions.map(reaction => String(reaction || '').trim()).filter(Boolean))];
716
+ if (!messageId || !emojiTypes.length)
717
+ return;
718
+ this._logOutgoing('setReaction', `msg_id=${messageId} reactions=${emojiTypes.join(',')}`);
719
+ for (const emojiType of emojiTypes) {
720
+ await this.client.im.messageReaction.create({
721
+ path: { message_id: messageId },
722
+ data: { reaction_type: { emoji_type: emojiType } },
723
+ }).catch(() => { });
724
+ }
725
+ }
713
726
  // ========================================================================
714
727
  // Streaming cards (CardKit v1) — typewriter effect
715
728
  // ========================================================================
@@ -97,6 +97,36 @@ export function shortValue(value, max = 90) {
97
97
  return text;
98
98
  return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
99
99
  }
100
+ export function normalizeErrorMessage(value) {
101
+ if (typeof value === 'string')
102
+ return value.trim();
103
+ if (value instanceof Error)
104
+ return value.message.trim();
105
+ if (Array.isArray(value)) {
106
+ return value.map(item => normalizeErrorMessage(item)).filter(Boolean).join('; ').trim();
107
+ }
108
+ if (value && typeof value === 'object') {
109
+ const record = value;
110
+ const preferred = normalizeErrorMessage(record.message)
111
+ || normalizeErrorMessage(record.error)
112
+ || normalizeErrorMessage(record.detail)
113
+ || normalizeErrorMessage(record.type)
114
+ || normalizeErrorMessage(record.code)
115
+ || normalizeErrorMessage(record.status);
116
+ if (preferred)
117
+ return preferred;
118
+ try {
119
+ return JSON.stringify(value).trim();
120
+ }
121
+ catch { }
122
+ }
123
+ return value == null ? '' : String(value).trim();
124
+ }
125
+ export function joinErrorMessages(errors) {
126
+ if (!errors?.length)
127
+ return '';
128
+ return errors.map(error => normalizeErrorMessage(error)).filter(Boolean).join('; ').trim();
129
+ }
100
130
  export function appendSystemPrompt(base, extra) {
101
131
  const lhs = String(base || '').trim();
102
132
  const rhs = String(extra || '').trim();
@@ -655,6 +685,7 @@ export async function run(cmd, opts, parseLine) {
655
685
  recentActivity: [],
656
686
  claudeToolsById: new Map(),
657
687
  seenClaudeToolIds: new Set(),
688
+ geminiToolsById: new Map(),
658
689
  };
659
690
  const shellCmd = cmd.map(Q).join(' ');
660
691
  agentLog(`[spawn] full command: cd ${Q(opts.workdir)} && ${shellCmd}`);
@@ -729,16 +760,17 @@ export async function run(cmd, opts, parseLine) {
729
760
  s.text = s.msgs.join('\n\n');
730
761
  if (!s.thinking.trim() && s.thinkParts.length)
731
762
  s.thinking = s.thinkParts.join('\n\n');
763
+ const errorText = joinErrorMessages(s.errors);
732
764
  const ok = procOk && !s.errors && !timedOut && !interrupted;
733
- const error = s.errors?.map(e => e.trim()).filter(Boolean).join('; ').trim()
765
+ const error = errorText
734
766
  || (interrupted ? 'Interrupted by user.' : null)
735
767
  || (timedOut ? `Timed out after ${opts.timeout}s before the agent reported completion.` : null)
736
768
  || (!procOk ? (stderr.trim() || `Failed (exit=${code}).`) : null);
737
769
  const incomplete = !ok || s.stopReason === 'max_tokens' || s.stopReason === 'timeout';
738
770
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
739
771
  agentLog(`[result] ok=${ok && !s.errors} elapsed=${elapsed}s text=${s.text.length}chars thinking=${s.thinking.length}chars session=${s.sessionId || '?'}`);
740
- if (s.errors)
741
- agentLog(`[result] errors: ${s.errors.join('; ')}`);
772
+ if (errorText)
773
+ agentLog(`[result] errors: ${errorText}`);
742
774
  if (s.stopReason)
743
775
  agentLog(`[result] stop_reason=${s.stopReason}`);
744
776
  if (stderr.trim() && !procOk)
@@ -746,7 +778,7 @@ export async function run(cmd, opts, parseLine) {
746
778
  return {
747
779
  ok, sessionId: s.sessionId, workspacePath: null,
748
780
  model: s.model, thinkingEffort: s.thinkingEffort,
749
- message: s.text.trim() || s.errors?.join('; ') || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
781
+ message: s.text.trim() || errorText || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
750
782
  thinking: s.thinking.trim() || null,
751
783
  elapsedS: (Date.now() - start) / 1000,
752
784
  inputTokens: s.inputTokens, outputTokens: s.outputTokens, cachedInputTokens: s.cachedInputTokens,
@@ -320,6 +320,20 @@ function buildCodexCumulativeUsage(raw) {
320
320
  return null;
321
321
  return { input: input ?? 0, output: output ?? 0, cached: cached ?? 0 };
322
322
  }
323
+ function buildCodexContextUsage(raw) {
324
+ if (!raw || typeof raw !== 'object')
325
+ return null;
326
+ const total = numberOrNull(raw.totalTokens, raw.total_tokens);
327
+ if (total != null && total >= 0)
328
+ return total;
329
+ const input = numberOrNull(raw.inputTokens, raw.input_tokens);
330
+ const output = numberOrNull(raw.outputTokens, raw.output_tokens);
331
+ if (input != null && output != null)
332
+ return input + output;
333
+ if (input != null)
334
+ return input;
335
+ return null;
336
+ }
323
337
  function applyCodexTokenUsage(s, rawUsage, prev) {
324
338
  if (!rawUsage || typeof rawUsage !== 'object')
325
339
  return;
@@ -337,6 +351,9 @@ function applyCodexTokenUsage(s, rawUsage, prev) {
337
351
  s.cachedInputTokens = lastCached;
338
352
  if (lastCacheCreation != null)
339
353
  s.cacheCreationInputTokens = lastCacheCreation;
354
+ const lastContextUsage = buildCodexContextUsage(last);
355
+ if (lastContextUsage != null)
356
+ s.contextUsedTokens = lastContextUsage;
340
357
  const totalUsage = info.total ?? info.totalTokenUsage ?? info.total_token_usage ?? rawUsage.total ?? rawUsage;
341
358
  const total = buildCodexCumulativeUsage(totalUsage);
342
359
  if (total) {
@@ -349,9 +366,9 @@ function applyCodexTokenUsage(s, rawUsage, prev) {
349
366
  s.cachedInputTokens = prev ? Math.max(0, total.cached - prev.cached) : total.cached;
350
367
  }
351
368
  // NOTE: do NOT set s.contextUsedTokens from cumulative totals —
352
- // codexUsageTotalTokens sums input+output+cached+reasoning across all turns,
353
- // which is NOT the current context-window occupancy. Let computeContext()
354
- // fall back to the per-turn input/cached/cacheCreation tokens instead.
369
+ // those counters span the full thread, not the current turn. Use the per-turn
370
+ // `last` usage only. `cached_input_tokens` is already a subset of
371
+ // `input_tokens`, so adding it again inflates the context percentage.
355
372
  const contextWindow = numberOrNull(info.modelContextWindow, info.model_context_window, rawUsage.modelContextWindow, rawUsage.model_context_window);
356
373
  if (contextWindow != null && contextWindow > 0)
357
374
  s.contextWindow = contextWindow;
@@ -7,7 +7,7 @@
7
7
  import { registerDriver } from './agent-driver.js';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
- import { run, agentLog, detectAgentBin, pushRecentActivity, listPikiclawSessions, isPendingSessionId, emptyUsage, } from './code-agent.js';
10
+ import { run, agentLog, detectAgentBin, pushRecentActivity, firstNonEmptyLine, shortValue, normalizeErrorMessage, listPikiclawSessions, isPendingSessionId, emptyUsage, } from './code-agent.js';
11
11
  // ---------------------------------------------------------------------------
12
12
  // Command & parser
13
13
  // ---------------------------------------------------------------------------
@@ -49,6 +49,101 @@ function geminiContextWindowFromModel(model) {
49
49
  return 1_048_576;
50
50
  return null;
51
51
  }
52
+ function geminiToolName(value) {
53
+ const name = typeof value === 'string' ? value.trim() : '';
54
+ return name || 'tool';
55
+ }
56
+ function geminiToolLabel(name) {
57
+ return name
58
+ .replace(/^mcp_/, '')
59
+ .replace(/^discovered_tool_/, '')
60
+ .replace(/_/g, ' ')
61
+ .replace(/\s+/g, ' ')
62
+ .trim() || 'tool';
63
+ }
64
+ function geminiToolSummary(name, parameters) {
65
+ const tool = geminiToolName(name);
66
+ const params = parameters && typeof parameters === 'object' ? parameters : {};
67
+ switch (tool) {
68
+ case 'read_file': {
69
+ const target = shortValue(params.file_path || params.path, 140);
70
+ return target ? `Read ${target}` : 'Read file';
71
+ }
72
+ case 'read_many_files': {
73
+ const include = shortValue(params.include || params.pattern, 120);
74
+ return include ? `Read files: ${include}` : 'Read files';
75
+ }
76
+ case 'write_file': {
77
+ const target = shortValue(params.file_path || params.path, 140);
78
+ return target ? `Write ${target}` : 'Write file';
79
+ }
80
+ case 'replace': {
81
+ const target = shortValue(params.file_path || params.path, 140);
82
+ return target ? `Edit ${target}` : 'Edit file';
83
+ }
84
+ case 'list_directory': {
85
+ const dir = shortValue(params.dir_path || params.path, 120);
86
+ return dir ? `List files: ${dir}` : 'List files';
87
+ }
88
+ case 'glob': {
89
+ const pattern = shortValue(params.pattern || params.glob, 120);
90
+ return pattern ? `Find files: ${pattern}` : 'Find files';
91
+ }
92
+ case 'grep_search':
93
+ case 'search_file_content': {
94
+ const pattern = shortValue(params.pattern || params.query, 120);
95
+ return pattern ? `Search text: ${pattern}` : 'Search text';
96
+ }
97
+ case 'run_shell_command': {
98
+ const command = shortValue(params.command, 120);
99
+ return command ? `Run shell: ${command}` : 'Run shell';
100
+ }
101
+ case 'web_fetch': {
102
+ const target = shortValue(params.url || params.prompt, 120);
103
+ return target ? `Fetch ${target}` : 'Fetch web page';
104
+ }
105
+ case 'google_web_search': {
106
+ const query = shortValue(params.query, 120);
107
+ return query ? `Search web: ${query}` : 'Search web';
108
+ }
109
+ case 'write_todos': return 'Update todo list';
110
+ case 'save_memory': return 'Save memory';
111
+ case 'ask_user': return 'Request user input';
112
+ case 'activate_skill': {
113
+ const skill = shortValue(params.name, 80);
114
+ return skill ? `Activate skill: ${skill}` : 'Activate skill';
115
+ }
116
+ case 'get_internal_docs': {
117
+ const target = shortValue(params.path, 120);
118
+ return target ? `Read docs: ${target}` : 'Read docs';
119
+ }
120
+ case 'enter_plan_mode': return 'Enter plan mode';
121
+ case 'exit_plan_mode': return 'Exit plan mode';
122
+ default: {
123
+ const detail = shortValue(params.file_path
124
+ || params.path
125
+ || params.dir_path
126
+ || params.pattern
127
+ || params.query
128
+ || params.command
129
+ || params.url
130
+ || params.name, 120);
131
+ const label = shortValue(geminiToolLabel(tool), 80);
132
+ return detail ? `Use ${label}: ${detail}` : `Use ${label}`;
133
+ }
134
+ }
135
+ }
136
+ function geminiToolResultSummary(tool, ev) {
137
+ const fallbackSummary = geminiToolSummary(tool?.name || ev.tool_name || ev.name || ev.tool, ev.parameters || ev.args || ev.input || {});
138
+ const summary = tool?.summary || fallbackSummary;
139
+ const detail = shortValue(firstNonEmptyLine(normalizeErrorMessage(ev.error)
140
+ || ev.output
141
+ || ev.message
142
+ || ''), 120);
143
+ if (ev.status === 'error')
144
+ return detail ? `${summary} failed: ${detail}` : `${summary} failed`;
145
+ return detail ? `${summary} -> ${detail}` : `${summary} done`;
146
+ }
52
147
  function geminiParse(ev, s) {
53
148
  const t = ev.type || '';
54
149
  // init event: {"type":"init","session_id":"...","model":"..."}
@@ -58,26 +153,46 @@ function geminiParse(ev, s) {
58
153
  s.contextWindow = geminiContextWindowFromModel(s.model) ?? s.contextWindow;
59
154
  }
60
155
  // message delta: {"type":"message","role":"assistant","content":"...","delta":true}
61
- if (t === 'message' && ev.role === 'assistant' && ev.delta) {
62
- s.text += ev.content || '';
156
+ if (t === 'message' && ev.role === 'assistant') {
157
+ if (ev.delta)
158
+ s.text += ev.content || '';
159
+ else if (!s.text.trim())
160
+ s.text = ev.content || '';
63
161
  }
64
- // tool_call event (if gemini uses tools)
65
- if (t === 'tool_call') {
66
- const name = ev.name || ev.tool || 'tool';
67
- pushRecentActivity(s.recentActivity, `Using ${name}...`);
162
+ if (t === 'tool_use' || t === 'tool_call') {
163
+ const name = geminiToolName(ev.tool_name || ev.name || ev.tool);
164
+ const summary = geminiToolSummary(name, ev.parameters || ev.args || ev.input || {});
165
+ const toolId = String(ev.tool_id || ev.id || '').trim();
166
+ if (toolId)
167
+ s.geminiToolsById.set(toolId, { name, summary });
168
+ pushRecentActivity(s.recentActivity, summary);
68
169
  s.activity = s.recentActivity.join('\n');
69
170
  }
70
- // tool_result event
71
171
  if (t === 'tool_result') {
72
- const name = ev.name || ev.tool || 'tool';
73
- pushRecentActivity(s.recentActivity, `${name} done`);
172
+ const toolId = String(ev.tool_id || ev.id || '').trim();
173
+ const tool = toolId ? s.geminiToolsById.get(toolId) : undefined;
174
+ pushRecentActivity(s.recentActivity, geminiToolResultSummary(tool, ev));
74
175
  s.activity = s.recentActivity.join('\n');
75
176
  }
177
+ if (t === 'error') {
178
+ const message = normalizeErrorMessage(ev.message || ev.error) || 'Gemini reported an error';
179
+ if (ev.severity === 'error') {
180
+ s.errors = [...(s.errors || []), message];
181
+ }
182
+ else {
183
+ pushRecentActivity(s.recentActivity, message);
184
+ s.activity = s.recentActivity.join('\n');
185
+ }
186
+ }
76
187
  // result event: {"type":"result","status":"success","stats":{...}}
77
188
  if (t === 'result') {
78
189
  s.sessionId = ev.session_id ?? s.sessionId;
79
190
  if (ev.status === 'error' || ev.status === 'failure') {
80
- s.errors = [ev.error || ev.message || `Gemini returned status: ${ev.status}`];
191
+ const message = normalizeErrorMessage(ev.error)
192
+ || normalizeErrorMessage(ev.errors)
193
+ || normalizeErrorMessage(ev.message)
194
+ || `Gemini returned status: ${ev.status}`;
195
+ s.errors = [message];
81
196
  }
82
197
  s.stopReason = ev.status === 'success' ? 'end_turn' : ev.status;
83
198
  const u = ev.stats;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,7 +31,7 @@
31
31
  "command": "set -ae && . ./.env && set +a && tsx src/run.ts",
32
32
  "build:dashboard": "tsx scripts/build-dashboard.ts",
33
33
  "build": "npm run build:dashboard && tsc",
34
- "prepublishOnly": "npm run build",
34
+ "prepack": "npm run build",
35
35
  "test": "vitest run",
36
36
  "test:e2e": "set -ae && . ./.env && set +a && vitest run --config vitest.e2e.config.ts test/e2e/",
37
37
  "test:watch": "vitest"