pikiclaw 0.2.68 → 0.2.70

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.
@@ -31,6 +31,12 @@ export function encodeCommandAction(action) {
31
31
  return `mod:${action.modelId}`;
32
32
  case 'effort.set':
33
33
  return `eff:${action.effort}`;
34
+ case 'models.select.model':
35
+ return `md:${action.modelId}`;
36
+ case 'models.select.effort':
37
+ return `ed:${action.effort}`;
38
+ case 'models.confirm':
39
+ return 'mc';
34
40
  case 'skill.run':
35
41
  return `skr:${action.command}`;
36
42
  }
@@ -70,6 +76,20 @@ export function decodeCommandAction(data) {
70
76
  return null;
71
77
  return { kind: 'effort.set', effort };
72
78
  }
79
+ if (data.startsWith('md:')) {
80
+ const modelId = data.slice(3);
81
+ if (!modelId)
82
+ return null;
83
+ return { kind: 'models.select.model', modelId };
84
+ }
85
+ if (data.startsWith('ed:')) {
86
+ const effort = data.slice(3);
87
+ if (!effort)
88
+ return null;
89
+ return { kind: 'models.select.effort', effort };
90
+ }
91
+ if (data === 'mc')
92
+ return { kind: 'models.confirm' };
73
93
  if (data.startsWith('skr:')) {
74
94
  const command = data.slice(4);
75
95
  if (!command)
@@ -134,24 +154,57 @@ export function buildAgentsCommandView(bot, chatId) {
134
154
  rows: chunkRows(actions, 3),
135
155
  };
136
156
  }
137
- export async function buildModelsCommandView(bot, chatId) {
157
+ const modelsDrafts = new Map();
158
+ async function initModelsDraft(bot, chatId) {
138
159
  const data = await getModelsListData(bot, chatId);
139
- const models = [...data.models].sort((a, b) => Number(b.isCurrent) - Number(a.isCurrent));
160
+ const draft = { modelId: data.currentModel, effort: data.effort?.current ?? null };
161
+ modelsDrafts.set(String(chatId), draft);
162
+ return draft;
163
+ }
164
+ export async function buildModelsCommandView(bot, chatId, draft) {
165
+ const data = await getModelsListData(bot, chatId);
166
+ // Initialize draft from current state or use the provided one
167
+ const d = draft ?? {
168
+ modelId: data.currentModel,
169
+ effort: data.effort?.current ?? null,
170
+ };
171
+ modelsDrafts.set(String(chatId), d);
172
+ const isSelected = (modelId) => modelMatchesSelection(data.agent, modelId, d.modelId);
173
+ const models = [...data.models].sort((a, b) => Number(isSelected(b.id)) - Number(isSelected(a.id)));
140
174
  const modelButtons = models.map(model => ({
141
175
  label: model.alias || model.id,
142
- action: { kind: 'model.switch', modelId: model.id },
143
- state: buttonStateFromFlags({ isCurrent: model.isCurrent }),
144
- primary: model.isCurrent,
176
+ action: { kind: 'models.select.model', modelId: model.id },
177
+ state: buttonStateFromFlags({ isCurrent: isSelected(model.id) }),
178
+ primary: isSelected(model.id),
145
179
  }));
146
- const rows = chunkRows(modelButtons, modelButtons.some(button => button.label.length > 14) ? 1 : 2);
180
+ const rows = chunkRows(modelButtons, 1);
147
181
  if (data.effort) {
148
- rows.push(data.effort.levels.map(level => ({
182
+ const effortButtons = data.effort.levels.map(level => ({
149
183
  label: level.label,
150
- action: { kind: 'effort.set', effort: level.id },
151
- state: buttonStateFromFlags({ isCurrent: level.isCurrent }),
152
- primary: level.isCurrent,
153
- })));
184
+ action: { kind: 'models.select.effort', effort: level.id },
185
+ state: buttonStateFromFlags({ isCurrent: level.id === d.effort }),
186
+ primary: level.id === d.effort,
187
+ }));
188
+ // Section label — clicking it is harmless (triggers confirm, which is noop if nothing changed)
189
+ rows.push([{
190
+ label: '— Thinking Effort —',
191
+ action: { kind: 'models.confirm' },
192
+ state: 'default',
193
+ primary: false,
194
+ }]);
195
+ // ≤3 levels fit in one row; 4+ split into rows of 2 to avoid Feishu truncation
196
+ rows.push(...chunkRows(effortButtons, effortButtons.length <= 3 ? effortButtons.length : 2));
154
197
  }
198
+ // Detect whether draft differs from current live values
199
+ const modelChanged = !modelMatchesSelection(data.agent, d.modelId, data.currentModel);
200
+ const effortChanged = !!(data.effort && d.effort !== data.effort.current);
201
+ const hasChanges = modelChanged || effortChanged;
202
+ rows.push([{
203
+ label: hasChanges ? '✓ Apply' : '✓ OK',
204
+ action: { kind: 'models.confirm' },
205
+ state: 'default',
206
+ primary: hasChanges,
207
+ }]);
155
208
  return {
156
209
  kind: 'models',
157
210
  title: 'Models',
@@ -159,15 +212,15 @@ export async function buildModelsCommandView(bot, chatId) {
159
212
  metaLines: [
160
213
  ...(data.sources.length ? [`Source: ${data.sources.join(', ')}`] : []),
161
214
  ...(data.note ? [data.note] : []),
162
- ...(data.effort ? [`Thinking Effort: ${data.effort.current}`] : []),
215
+ ...(data.effort ? [`Thinking Effort: ${d.effort}`] : []),
163
216
  ],
164
217
  items: models.map(model => ({
165
218
  label: model.alias || model.id,
166
219
  detail: model.alias ? model.id : null,
167
- state: buttonStateFromFlags({ isCurrent: model.isCurrent }),
220
+ state: buttonStateFromFlags({ isCurrent: isSelected(model.id) }),
168
221
  })),
169
222
  emptyText: 'No discoverable models found.',
170
- helperText: data.models.length ? 'Use the controls below to switch models.' : null,
223
+ helperText: data.models.length ? 'Select model and effort, then tap Apply.' : null,
171
224
  rows,
172
225
  };
173
226
  }
@@ -286,6 +339,51 @@ export async function executeCommandAction(bot, chatId, action, opts = {}) {
286
339
  },
287
340
  };
288
341
  }
342
+ case 'models.select.model': {
343
+ const draft = modelsDrafts.get(String(chatId)) ?? await initModelsDraft(bot, chatId);
344
+ draft.modelId = action.modelId;
345
+ return { kind: 'view', view: await buildModelsCommandView(bot, chatId, draft), callbackText: '' };
346
+ }
347
+ case 'models.select.effort': {
348
+ const draft = modelsDrafts.get(String(chatId)) ?? await initModelsDraft(bot, chatId);
349
+ draft.effort = action.effort;
350
+ return { kind: 'view', view: await buildModelsCommandView(bot, chatId, draft), callbackText: '' };
351
+ }
352
+ case 'models.confirm': {
353
+ const chat = bot.chat(chatId);
354
+ const draft = modelsDrafts.get(String(chatId));
355
+ modelsDrafts.delete(String(chatId));
356
+ if (!draft)
357
+ return { kind: 'noop', message: 'No changes' };
358
+ const currentModel = bot.modelForAgent(chat.agent);
359
+ const currentEffort = bot.effortForAgent(chat.agent);
360
+ const modelChanged = !modelMatchesSelection(chat.agent, draft.modelId, currentModel);
361
+ const effortChanged = draft.effort != null && draft.effort !== currentEffort;
362
+ if (!modelChanged && !effortChanged) {
363
+ return { kind: 'noop', message: 'No changes' };
364
+ }
365
+ const parts = [];
366
+ if (modelChanged) {
367
+ bot.switchModelForChat(chatId, draft.modelId);
368
+ parts.push(`Model: ${draft.modelId}`);
369
+ }
370
+ if (effortChanged) {
371
+ bot.switchEffortForChat(chatId, draft.effort);
372
+ parts.push(`Effort: ${draft.effort}`);
373
+ }
374
+ return {
375
+ kind: 'notice',
376
+ callbackText: parts.join(', '),
377
+ notice: {
378
+ title: 'Configuration Updated',
379
+ value: parts.join('\n'),
380
+ detail: modelChanged
381
+ ? `${chat.agent} · session reset`
382
+ : `${chat.agent} · takes effect on next message`,
383
+ valueMode: 'plain',
384
+ },
385
+ };
386
+ }
289
387
  case 'skill.run': {
290
388
  const resolved = resolveSkillPrompt(bot, chatId, action.command, '');
291
389
  if (!resolved)
@@ -249,25 +249,16 @@ export function resolveSkillPrompt(bot, chatId, cmd, args) {
249
249
  const cs = bot.chat(chatId);
250
250
  const extra = args.trim();
251
251
  const suffix = extra ? ` Additional context: ${extra}` : '';
252
+ const workdirHint = `[Project directory: ${bot.workdir}]\n\n`;
252
253
  let prompt;
253
- if (skill.source === 'commands') {
254
- prompt = `In this project's .claude/commands/${skill.name}.md file, there is a custom command definition. Please read and execute the instructions defined there.${suffix}`;
255
- return { prompt, skillName: skill.name };
256
- }
257
254
  const paths = getProjectSkillPaths(bot.workdir, skill.name);
258
- if (cs.agent === 'claude' && paths.claudeSkillFile) {
259
- prompt = `Please execute the /${skill.name} skill defined in this project.${suffix}`;
255
+ const skillFile = paths.claudeSkillFile || paths.sharedSkillFile || paths.agentsSkillFile;
256
+ if (skillFile) {
257
+ prompt = `${workdirHint}Read the skill definition at \`${skillFile}\` and execute the instructions defined there.${suffix}`;
260
258
  }
261
259
  else {
262
- const canonicalPath = paths.sharedSkillFile
263
- ? `\`${relSkillPath(bot.workdir, paths.sharedSkillFile)}\``
264
- : `\`.pikiclaw/skills/${skill.name}/SKILL.md\``;
265
- const locationText = paths.sharedSkillFile
266
- ? canonicalPath
267
- : paths.agentsSkillFile || paths.claudeSkillFile
268
- ? canonicalPath
269
- : `\`${skill.name}/SKILL.md\``;
270
- prompt = `In this project, the ${skill.name} skill is defined in ${locationText}. Please read that SKILL.md file and execute the instructions.${suffix}`;
260
+ const fallbackPath = `${bot.workdir}/.pikiclaw/skills/${skill.name}/SKILL.md`;
261
+ prompt = `${workdirHint}Read the skill definition at \`${fallbackPath}\` and execute the instructions defined there.${suffix}`;
271
262
  }
272
263
  return { prompt, skillName: skill.name };
273
264
  }
@@ -100,6 +100,29 @@ export function renderCommandSelectionCard(view) {
100
100
  rows: view.rows.map(row => ({ actions: row.map(actionButton) })),
101
101
  };
102
102
  }
103
+ /**
104
+ * Strip code-fence markers (``` / ~~~) from text that is not meant to be
105
+ * rendered as full markdown (thinking, activity). Truncation by
106
+ * extractThinkingTail can leave stray fences that open unwanted code blocks.
107
+ */
108
+ function stripCodeFences(text) {
109
+ return text.replace(/^(`{3,}|~{3,}).*$/gm, '');
110
+ }
111
+ /**
112
+ * Ensure code fences in markdown text are balanced. If an odd number of
113
+ * fence markers is detected, append a closing fence so partial code blocks
114
+ * do not swallow the rest of the card.
115
+ */
116
+ function ensureBalancedCodeFences(text) {
117
+ let inCode = false;
118
+ for (const line of text.split('\n')) {
119
+ const trimmed = line.trimStart();
120
+ if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
121
+ inCode = !inCode;
122
+ }
123
+ }
124
+ return inCode ? text + '\n```' : text;
125
+ }
103
126
  function escapeFeishuMarkdownText(text) {
104
127
  return text.replace(/([\\`*_{}[\]()#+\-.!|>~])/g, '\\$1');
105
128
  }
@@ -172,19 +195,19 @@ function buildPreviewMarkdown(input, options) {
172
195
  const data = extractStreamPreviewData(input);
173
196
  const parts = [];
174
197
  if (data.planDisplay) {
175
- parts.push(`**Plan**\n${data.planDisplay}`);
198
+ parts.push(`**Plan**\n${stripCodeFences(data.planDisplay)}`);
176
199
  }
177
200
  if (data.activityDisplay) {
178
- parts.push(`**Activity**\n${trimActivityForPreview(data.activityDisplay, data.maxActivity)}`);
201
+ parts.push(`**Activity**\n${stripCodeFences(trimActivityForPreview(data.activityDisplay, data.maxActivity))}`);
179
202
  }
180
203
  if (data.thinkDisplay && !data.display) {
181
- parts.push(`**${data.label}**\n${data.thinkDisplay}`);
204
+ parts.push(`**${data.label}**\n${stripCodeFences(data.thinkDisplay)}`);
182
205
  }
183
206
  else if (data.display) {
184
207
  if (data.rawThinking) {
185
- parts.push(`**${data.label}**\n${data.thinkSnippet}`);
208
+ parts.push(`**${data.label}**\n${stripCodeFences(data.thinkSnippet)}`);
186
209
  }
187
- parts.push(data.preview);
210
+ parts.push(ensureBalancedCodeFences(data.preview));
188
211
  }
189
212
  if (options?.includeFooter !== false) {
190
213
  parts.push(formatPreviewFooter(input.agent, input.elapsedMs, input.meta ?? null));
@@ -213,21 +236,21 @@ export function buildFinalReplyRender(agent, result) {
213
236
  let activityText = '';
214
237
  let activityNoteText = '';
215
238
  if (data.activityNarrative) {
216
- activityText = `**Activity**\n${data.activityNarrative}\n\n`;
239
+ activityText = `**Activity**\n${stripCodeFences(data.activityNarrative)}\n\n`;
217
240
  }
218
241
  if (data.activityCommandSummary) {
219
242
  activityNoteText = `*${data.activityCommandSummary}*\n\n`;
220
243
  }
221
244
  let thinkingText = '';
222
245
  if (data.thinkingDisplay) {
223
- thinkingText = `**${data.thinkLabel}**\n${data.thinkingDisplay}\n\n`;
246
+ thinkingText = `**${data.thinkLabel}**\n${stripCodeFences(data.thinkingDisplay)}\n\n`;
224
247
  }
225
248
  let statusText = '';
226
249
  if (data.statusLines) {
227
250
  statusText = `**⚠ Incomplete Response**\n${data.statusLines.join('\n')}\n\n`;
228
251
  }
229
252
  const headerText = `${activityText}${activityNoteText}${statusText}${thinkingText}`;
230
- const bodyText = data.bodyMessage;
253
+ const bodyText = ensureBalancedCodeFences(data.bodyMessage);
231
254
  return {
232
255
  fullText: `${headerText}${bodyText}${footerText}`,
233
256
  headerText,
@@ -583,6 +606,10 @@ function normalizeFeishuMarkdown(lines) {
583
606
  pendingBlankLine = false;
584
607
  out.push(line);
585
608
  }
609
+ // Safety: close any unclosed code block so it doesn't swallow the rest of
610
+ // a Feishu card element (e.g. truncated body text ending mid-fence).
611
+ if (inCodeBlock)
612
+ out.push('```');
586
613
  return out.join('\n');
587
614
  }
588
615
  export function adaptMarkdownForFeishu(markdown) {
@@ -651,7 +651,8 @@ export class TelegramBot extends Bot {
651
651
  finalMsgId = await replacePreview(rendered.fullHtml);
652
652
  }
653
653
  else {
654
- const maxFirst = 3900 - rendered.headerHtml.length - rendered.footerHtml.length;
654
+ // Split: header on first message, footer on last message
655
+ const maxFirst = 3900 - rendered.headerHtml.length;
655
656
  let firstBody;
656
657
  let remaining;
657
658
  if (maxFirst > 200) {
@@ -665,13 +666,28 @@ export class TelegramBot extends Bot {
665
666
  firstBody = '';
666
667
  remaining = rendered.bodyHtml;
667
668
  }
668
- const firstHtml = `${rendered.headerHtml}${firstBody}${rendered.footerHtml}`;
669
- finalMsgId = await replacePreview(firstHtml);
670
669
  if (remaining.trim()) {
670
+ // Multi-message: header on first, footer on last
671
+ const firstHtml = `${rendered.headerHtml}${firstBody}`;
672
+ finalMsgId = await replacePreview(firstHtml);
671
673
  const chunks = splitText(remaining, 3800);
672
- for (const chunk of chunks) {
673
- remember(await sendFinalText(chunk, finalMsgId ?? phId ?? ctx.messageId));
674
+ for (let i = 0; i < chunks.length; i++) {
675
+ const isLast = i === chunks.length - 1;
676
+ const chunkText = isLast ? `${chunks[i]}${rendered.footerHtml}` : chunks[i];
677
+ remember(await sendFinalText(chunkText, finalMsgId ?? phId ?? ctx.messageId));
674
678
  }
679
+ // Safety: re-clear the Stop keyboard on the placeholder in case the first edit silently failed
680
+ if (phId != null) {
681
+ try {
682
+ await this.channel.editMessage(ctx.chatId, phId, firstHtml || '(done)', { parseMode: 'HTML', keyboard: { inline_keyboard: [] } });
683
+ }
684
+ catch { }
685
+ }
686
+ }
687
+ else {
688
+ // Body fits on first message; only footer pushes it over — keep together
689
+ const firstHtml = `${rendered.headerHtml}${firstBody}${rendered.footerHtml}`;
690
+ finalMsgId = await replacePreview(firstHtml);
675
691
  }
676
692
  }
677
693
  return { primaryMessageId: finalMsgId, messageIds };
package/dist/cli.js CHANGED
@@ -299,10 +299,15 @@ Docs: https://github.com/xiaotonng/pikiclaw
299
299
  */
300
300
  function persistWorkdir(args, userConfig) {
301
301
  if (!process.env.PIKICLAW_DAEMON_CHILD) {
302
- const cliWorkdir = path.resolve(args.workdir || '.');
303
- if (userConfig.workdir !== cliWorkdir) {
304
- updateUserConfig({ workdir: cliWorkdir });
305
- return loadUserConfig();
302
+ // Only overwrite persisted workdir when the user explicitly passed -w.
303
+ // Falling back to cwd when no flag is given can clobber a valid saved
304
+ // workdir with a temp directory (e.g. after a non-daemon restart).
305
+ if (args.workdir) {
306
+ const cliWorkdir = path.resolve(args.workdir);
307
+ if (userConfig.workdir !== cliWorkdir) {
308
+ updateUserConfig({ workdir: cliWorkdir });
309
+ return loadUserConfig();
310
+ }
306
311
  }
307
312
  }
308
313
  return userConfig;
@@ -1209,32 +1209,15 @@ export function getProjectSkillPaths(workdir, skillName) {
1209
1209
  const sharedSkillFile = path.join(workdir, '.pikiclaw', 'skills', skillName, 'SKILL.md');
1210
1210
  const agentsSkillFile = path.join(workdir, '.agents', 'skills', skillName, 'SKILL.md');
1211
1211
  const claudeSkillFile = path.join(workdir, '.claude', 'skills', skillName, 'SKILL.md');
1212
- const claudeCommandFile = path.join(workdir, '.claude', 'commands', `${skillName}.md`);
1213
1212
  return {
1214
1213
  sharedSkillFile: hasFile(sharedSkillFile) ? sharedSkillFile : null,
1215
1214
  agentsSkillFile: hasFile(agentsSkillFile) ? agentsSkillFile : null,
1216
1215
  claudeSkillFile: hasFile(claudeSkillFile) ? claudeSkillFile : null,
1217
- claudeCommandFile: hasFile(claudeCommandFile) ? claudeCommandFile : null,
1218
1216
  };
1219
1217
  }
1220
1218
  export function listSkills(workdir) {
1221
1219
  const skills = [];
1222
1220
  const seen = new Set();
1223
- const commandsDir = path.join(workdir, '.claude', 'commands');
1224
- for (const entry of readSortedDir(commandsDir)) {
1225
- if (!entry.endsWith('.md'))
1226
- continue;
1227
- const name = entry.replace(/\.md$/, '');
1228
- if (!name || seen.has(name))
1229
- continue;
1230
- let meta = { label: null, description: null };
1231
- try {
1232
- meta = parseSkillMeta(fs.readFileSync(path.join(commandsDir, entry), 'utf-8'));
1233
- }
1234
- catch { }
1235
- skills.push({ name, label: meta.label, description: meta.description, source: 'commands' });
1236
- seen.add(name);
1237
- }
1238
1221
  const skillRoots = [
1239
1222
  path.join(workdir, '.pikiclaw', 'skills'),
1240
1223
  ];
@@ -223,9 +223,10 @@ function getNativeClaudeSessions(workdir) {
223
223
  const filePath = path.join(projectDir, entry.name);
224
224
  try {
225
225
  const stat = fs.statSync(filePath);
226
- // Read first few KB to extract title and model from first user/assistant messages
226
+ // Read enough bytes to get past the system_prompt line (can be 20KB+) and
227
+ // reach the first user/assistant events for title and model extraction.
227
228
  const fd = fs.openSync(filePath, 'r');
228
- const buf = Buffer.alloc(8192);
229
+ const buf = Buffer.alloc(65536);
229
230
  const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
230
231
  fs.closeSync(fd);
231
232
  const head = buf.toString('utf8', 0, bytesRead);
@@ -168,6 +168,7 @@ function spawnReplacementProcess(bin, args, env, log) {
168
168
  stdio: 'inherit',
169
169
  detached: true,
170
170
  env,
171
+ cwd: process.cwd(),
171
172
  });
172
173
  child.unref();
173
174
  log?.(`restart: new process spawned (PID ${child.pid})`);
package/dist/run.js CHANGED
@@ -112,14 +112,13 @@ async function main() {
112
112
  case 'skills': {
113
113
  const result = listSkills(workdir);
114
114
  if (!result.skills.length) {
115
- process.stdout.write(`No custom skills found in ${workdir} (.pikiclaw/skills, .claude/commands)\n`);
115
+ process.stdout.write(`No custom skills found in ${workdir} (.pikiclaw/skills)\n`);
116
116
  break;
117
117
  }
118
118
  process.stdout.write(`Project skills (${result.skills.length}):\n\n`);
119
119
  for (const sk of result.skills) {
120
- const src = sk.source === 'skills' ? 'skill' : 'command';
121
120
  const desc = sk.description ? ` ${sk.description}` : '';
122
- process.stdout.write(` ${sk.name} [${src}]${desc}\n`);
121
+ process.stdout.write(` ${sk.name}${desc}\n`);
123
122
  }
124
123
  break;
125
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.2.68",
3
+ "version": "0.2.70",
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": {