pikiclaw 0.3.31 → 0.3.32

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.
@@ -8,7 +8,7 @@ import { createInterface } from 'node:readline';
8
8
  import { registerDriver } from '../driver.js';
9
9
  import {
10
10
  // shared helpers
11
- Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, IMAGE_EXTS, mimeForExt, listPikiclawSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, applyTurnWindow, shortValue, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, } from '../index.js';
11
+ Q, run, agentError, agentLog, agentWarn, buildStreamPreviewMeta, computeContext, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, joinErrorMessages, parseTodoWriteAsPlan, IMAGE_EXTS, mimeForExt, listPikiclawSessions, mergeManagedAndNativeSessions, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, applyTurnWindow, shortValue, roundPercent, modelFamily, normalizeClaudeModelId, emptyUsage, normalizeUsageStatus, } from '../index.js';
12
12
  import { AGENT_STREAM_HARD_KILL_GRACE_MS, AGENT_GRACEFUL_ABORT_GRACE_MS, SESSION_RUNNING_THRESHOLD_MS } from '../../core/constants.js';
13
13
  import { terminateProcessTree } from '../../core/process-control.js';
14
14
  import { getHome, IS_MAC, encodePathAsDirName } from '../../core/platform.js';
@@ -714,7 +714,11 @@ async function doClaudeInteractiveStream(opts) {
714
714
  cacheCreationInputTokens: s.cacheCreationInputTokens,
715
715
  contextWindow: s.contextWindow,
716
716
  contextUsedTokens: s.contextUsedTokens,
717
- contextPercent: roundPercent((s.contextUsedTokens || 0) / (s.contextWindow || 0)),
717
+ // Reuse the same calc as the live preview (computeContext) so the final
718
+ // footer % matches the running %. Previously this passed a fraction
719
+ // (used/window) into roundPercent, which expects a percent — divide-by-100
720
+ // bug that made the final read ~12% as ~0.1%.
721
+ contextPercent: computeContext(s).contextPercent,
718
722
  codexCumulative: null,
719
723
  error,
720
724
  plan: s.plan,
@@ -451,11 +451,13 @@ function findGeminiSessionFile(workdir, sessionId) {
451
451
  return null;
452
452
  }
453
453
  for (const entry of entries) {
454
- if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json'))
454
+ if (!entry.isFile() || !entry.name.startsWith('session-'))
455
+ continue;
456
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
455
457
  continue;
456
458
  const filePath = path.join(chatsDir, entry.name);
457
459
  try {
458
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
460
+ const data = loadGeminiSessionData(filePath);
459
461
  if (data?.sessionId === sessionId)
460
462
  return filePath;
461
463
  }
@@ -463,12 +465,46 @@ function findGeminiSessionFile(workdir, sessionId) {
463
465
  }
464
466
  return null;
465
467
  }
468
+ function loadGeminiSessionData(filePath) {
469
+ try {
470
+ const content = fs.readFileSync(filePath, 'utf8');
471
+ if (filePath.endsWith('.json'))
472
+ return JSON.parse(content);
473
+ // JSONL format: first line is metadata, subsequent lines are messages or $set updates
474
+ const lines = content.split('\n');
475
+ let data = {};
476
+ const messages = [];
477
+ for (const line of lines) {
478
+ if (!line.trim() || line[0] !== '{')
479
+ continue;
480
+ try {
481
+ const obj = JSON.parse(line);
482
+ if (obj.sessionId && !data.sessionId) {
483
+ data = { ...obj };
484
+ }
485
+ else if (obj.$set) {
486
+ if (obj.$set.lastUpdated)
487
+ data.lastUpdated = obj.$set.lastUpdated;
488
+ }
489
+ else if (obj.type === 'user' || obj.type === 'gemini' || obj.type === 'model' || obj.type === 'assistant') {
490
+ messages.push(obj);
491
+ }
492
+ }
493
+ catch { /* skip */ }
494
+ }
495
+ data.messages = messages;
496
+ return data;
497
+ }
498
+ catch {
499
+ return null;
500
+ }
501
+ }
466
502
  /** Read native Gemini CLI sessions from ~/.gemini/tmp/{projectName}/chats/ */
467
503
  function getNativeGeminiSessionsFromFiles(workdir) {
468
504
  const chatsDir = geminiChatsDir(workdir);
469
505
  if (!chatsDir || !fs.existsSync(chatsDir))
470
506
  return [];
471
- const sessions = [];
507
+ const sessionsById = new Map();
472
508
  let entries;
473
509
  try {
474
510
  entries = fs.readdirSync(chatsDir, { withFileTypes: true });
@@ -477,13 +513,22 @@ function getNativeGeminiSessionsFromFiles(workdir) {
477
513
  return [];
478
514
  }
479
515
  for (const entry of entries) {
480
- if (!entry.isFile() || !entry.name.startsWith('session-') || !entry.name.endsWith('.json'))
516
+ if (!entry.isFile() || !entry.name.startsWith('session-'))
517
+ continue;
518
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.jsonl'))
481
519
  continue;
482
520
  const filePath = path.join(chatsDir, entry.name);
483
521
  try {
484
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
485
- if (!data.sessionId)
522
+ const data = loadGeminiSessionData(filePath);
523
+ if (!data?.sessionId)
486
524
  continue;
525
+ const sessionId = String(data.sessionId);
526
+ const updatedAt = data.lastUpdated || data.startTime || data.createdAt || null;
527
+ // If we already saw this sessionId, only replace it if this file is newer
528
+ const existing = sessionsById.get(sessionId);
529
+ if (existing && updatedAt && existing.runUpdatedAt && Date.parse(updatedAt) <= Date.parse(existing.runUpdatedAt)) {
530
+ continue;
531
+ }
487
532
  // Extract title from first user message + last Q&A from tail
488
533
  let title = null;
489
534
  let lastQuestion = null;
@@ -500,7 +545,7 @@ function getNativeGeminiSessionsFromFiles(workdir) {
500
545
  lastMessageText = shortValue(text, 500);
501
546
  }
502
547
  }
503
- else if (msg.type === 'model' || msg.type === 'assistant') {
548
+ else if (msg.type === 'model' || msg.type === 'assistant' || msg.type === 'gemini') {
504
549
  const text = extractGeminiText(msg.content);
505
550
  if (text) {
506
551
  lastAnswer = shortValue(text, 500);
@@ -509,18 +554,18 @@ function getNativeGeminiSessionsFromFiles(workdir) {
509
554
  }
510
555
  }
511
556
  const numTurns = messages.filter((m) => m.type === 'user' && extractGeminiText(m.content)).length;
512
- sessions.push({
513
- sessionId: data.sessionId,
557
+ sessionsById.set(sessionId, {
558
+ sessionId,
514
559
  agent: 'gemini',
515
560
  workdir,
516
561
  workspacePath: null,
517
562
  model: null,
518
- createdAt: data.startTime || null,
563
+ createdAt: data.startTime || data.createdAt || null,
519
564
  title,
520
565
  running: data.lastUpdated ? Date.now() - Date.parse(data.lastUpdated) < SESSION_RUNNING_THRESHOLD_MS : false,
521
566
  runState: data.lastUpdated && Date.now() - Date.parse(data.lastUpdated) < SESSION_RUNNING_THRESHOLD_MS ? 'running' : 'completed',
522
567
  runDetail: null,
523
- runUpdatedAt: data.lastUpdated || data.startTime || null,
568
+ runUpdatedAt: updatedAt || null,
524
569
  classification: null,
525
570
  userStatus: null,
526
571
  userNote: null,
@@ -535,7 +580,7 @@ function getNativeGeminiSessionsFromFiles(workdir) {
535
580
  }
536
581
  catch { /* skip */ }
537
582
  }
538
- return sessions;
583
+ return [...sessionsById.values()];
539
584
  }
540
585
  function getNativeGeminiSessions(workdir) {
541
586
  return getNativeGeminiSessionsFromFiles(workdir);
@@ -583,12 +628,12 @@ function getGeminiSessionTail(opts) {
583
628
  if (!filePath)
584
629
  return { ok: false, messages: [], error: 'Session file not found' };
585
630
  try {
586
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
631
+ const data = loadGeminiSessionData(filePath);
587
632
  const messages = Array.isArray(data?.messages) ? data.messages : [];
588
633
  const allMsgs = [];
589
634
  for (const msg of messages) {
590
635
  const type = typeof msg?.type === 'string' ? msg.type.trim().toLowerCase() : '';
591
- const role = type === 'user' ? 'user' : type === 'gemini' ? 'assistant' : null;
636
+ const role = type === 'user' ? 'user' : (type === 'gemini' || type === 'model' || type === 'assistant') ? 'assistant' : null;
592
637
  if (!role)
593
638
  continue;
594
639
  const text = extractGeminiText(msg?.content);
@@ -609,12 +654,12 @@ function getGeminiSessionMessages(opts) {
609
654
  if (!filePath)
610
655
  return { ok: false, messages: [], totalTurns: 0, error: 'Session file not found' };
611
656
  try {
612
- const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
657
+ const data = loadGeminiSessionData(filePath);
613
658
  const messages = Array.isArray(data?.messages) ? data.messages : [];
614
659
  const allMsgs = [];
615
660
  for (const msg of messages) {
616
661
  const type = typeof msg?.type === 'string' ? msg.type.trim().toLowerCase() : '';
617
- const role = type === 'user' ? 'user' : type === 'gemini' ? 'assistant' : null;
662
+ const role = type === 'user' ? 'user' : (type === 'gemini' || type === 'model' || type === 'assistant') ? 'assistant' : null;
618
663
  if (!role)
619
664
  continue;
620
665
  const text = extractGeminiText(msg?.content);
@@ -62,17 +62,27 @@ function compactModelLabel(model) {
62
62
  const slashIdx = trimmed.indexOf('/');
63
63
  return slashIdx > 0 ? trimmed.slice(slashIdx + 1) : trimmed;
64
64
  }
65
- export function formatFooterSummary(agent, elapsedMs, meta, contextPercent, decorations) {
66
- const parts = [agent];
65
+ /**
66
+ * Split footer fields into a primary identity line (agent + model) and a
67
+ * secondary runtime line (effort, context%, elapsed). Channel renderers
68
+ * compose the two lines with their own visual styling so narrow IM clients
69
+ * never have to soft-wrap a single dense line.
70
+ */
71
+ export function formatFooterParts(agent, elapsedMs, meta, contextPercent, decorations) {
72
+ const identityParts = [agent];
67
73
  if (decorations?.model)
68
- parts.push(compactModelLabel(decorations.model));
74
+ identityParts.push(compactModelLabel(decorations.model));
75
+ const runtimeParts = [];
69
76
  if (decorations?.effort)
70
- parts.push(decorations.effort);
77
+ runtimeParts.push(decorations.effort);
71
78
  const ctx = contextPercent ?? meta?.contextPercent ?? null;
72
79
  if (ctx != null)
73
- parts.push(`${ctx}%`);
74
- parts.push(fmtCompactUptime(Math.max(0, Math.round(elapsedMs))));
75
- return parts.join(' · ');
80
+ runtimeParts.push(`${ctx}%`);
81
+ runtimeParts.push(fmtCompactUptime(Math.max(0, Math.round(elapsedMs))));
82
+ return {
83
+ identity: identityParts.join(' · '),
84
+ runtime: runtimeParts.join(' · '),
85
+ };
76
86
  }
77
87
  // ---------------------------------------------------------------------------
78
88
  // Activity trimming
@@ -139,10 +149,6 @@ export function buildProviderUsageLines(usage) {
139
149
  export function extractFinalReplyData(agent, result) {
140
150
  const footerStatus = result.incomplete || !result.ok ? 'failed' : 'done';
141
151
  const elapsedMs = result.elapsedS * 1000;
142
- const footerSummary = formatFooterSummary(agent, elapsedMs, null, result.contextPercent ?? null, {
143
- model: result.model,
144
- effort: result.thinkingEffort,
145
- });
146
152
  let activityNarrative = null;
147
153
  let activityCommandSummary = null;
148
154
  if (result.activity) {
@@ -177,7 +183,6 @@ export function extractFinalReplyData(agent, result) {
177
183
  }
178
184
  return {
179
185
  footerStatus,
180
- footerSummary,
181
186
  activityNarrative,
182
187
  activityCommandSummary,
183
188
  thinkingDisplay,
@@ -7,7 +7,7 @@
7
7
  import { encodeCommandAction } from '../../bot/command-ui.js';
8
8
  import { fmtUptime, fmtTokens, fmtBytes } from '../../bot/bot.js';
9
9
  import { summarizePromptForStatus } from '../../bot/commands.js';
10
- import { footerStatusSymbol, formatFooterSummary, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, } from '../../bot/render-shared.js';
10
+ import { footerStatusSymbol, formatFooterParts, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, } from '../../bot/render-shared.js';
11
11
  import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from '../../bot/human-loop.js';
12
12
  import path from 'node:path';
13
13
  import { listSubdirs } from '../../bot/bot.js';
@@ -15,10 +15,12 @@ import { listSubdirs } from '../../bot/bot.js';
15
15
  // Helpers
16
16
  // ---------------------------------------------------------------------------
17
17
  function formatPreviewFooter(agent, elapsedMs, meta, decorations) {
18
- return `${footerStatusSymbol('running')} ${formatFooterSummary(agent, elapsedMs, meta, null, decorations)}`;
18
+ const parts = formatFooterParts(agent, elapsedMs, meta, null, decorations);
19
+ return `${footerStatusSymbol('running')} ${parts.identity}\n*${parts.runtime}*`;
19
20
  }
20
21
  function formatFinalFooter(status, agent, elapsedMs, contextPercent, decorations) {
21
- return `${footerStatusSymbol(status)} ${formatFooterSummary(agent, elapsedMs, null, contextPercent ?? null, decorations)}`;
22
+ const parts = formatFooterParts(agent, elapsedMs, null, contextPercent ?? null, decorations);
23
+ return `${footerStatusSymbol(status)} ${parts.identity}\n*${parts.runtime}*`;
22
24
  }
23
25
  function truncateLabel(label, maxChars = 24) {
24
26
  return label.length > maxChars ? `${label.slice(0, Math.max(1, maxChars - 1))}…` : label;
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { encodeCommandAction } from '../../bot/command-ui.js';
5
5
  import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from '../../bot/human-loop.js';
6
- import { footerStatusSymbol, formatFooterSummary, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, parseGfmTable, } from '../../bot/render-shared.js';
6
+ import { footerStatusSymbol, formatFooterParts, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, parseGfmTable, } from '../../bot/render-shared.js';
7
7
  export function escapeHtml(t) {
8
8
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
9
9
  }
@@ -256,10 +256,14 @@ export function renderSkillsListHtml(d) {
256
256
  return lines.join('\n');
257
257
  }
258
258
  export function formatPreviewFooterHtml(agent, elapsedMs, meta, decorations) {
259
- return escapeHtml(`${footerStatusSymbol('running')} ${formatFooterSummary(agent, elapsedMs, meta, null, decorations)}`);
259
+ const parts = formatFooterParts(agent, elapsedMs, meta, null, decorations);
260
+ const primary = escapeHtml(`${footerStatusSymbol('running')} ${parts.identity}`);
261
+ return `${primary}\n<i>${escapeHtml(parts.runtime)}</i>`;
260
262
  }
261
263
  function formatFinalFooterHtml(status, agent, elapsedMs, contextPercent, decorations) {
262
- return escapeHtml(`${footerStatusSymbol(status)} ${formatFooterSummary(agent, elapsedMs, null, contextPercent ?? null, decorations)}`);
264
+ const parts = formatFooterParts(agent, elapsedMs, null, contextPercent ?? null, decorations);
265
+ const primary = escapeHtml(`${footerStatusSymbol(status)} ${parts.identity}`);
266
+ return `${primary}\n<i>${escapeHtml(parts.runtime)}</i>`;
263
267
  }
264
268
  export function formatProviderUsageLines(usage) {
265
269
  return buildProviderUsageLines(usage).map(line => line.bold ? `<b>${escapeHtml(line.text)}</b>` : escapeHtml(line.text));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
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": {