pikiloom 0.4.16 → 0.4.18

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 (36) hide show
  1. package/README.md +2 -1
  2. package/dashboard/dist/assets/{AgentTab-B5tmLxa7.js → AgentTab-CDVhy5K1.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-CBp5nyfS.js → DirBrowser-BElI1-4D.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-w4pkrNas.js → ExtensionsTab-BB8ipJ77.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-37Po5LP1.js → IMAccessTab-IZt_yXoG.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-CBMO5UcS.js → Modal-C1EAGSL1.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-DMlEjJUG.js → Modals-DYUV5yR9.js} +1 -1
  8. package/dashboard/dist/assets/{Select-BiSTkS_t.js → Select-BnsbE6Qv.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-Ca_TVTT1.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-Brzt5wTT.js → SystemTab-Dk6k2OTt.js} +1 -1
  11. package/dashboard/dist/assets/{index-5Q-Q7ByM.js → index-CK-3CNRp.js} +2 -2
  12. package/dashboard/dist/assets/index-CnJsD381.js +23 -0
  13. package/dashboard/dist/assets/index-dzfjF9Js.css +1 -0
  14. package/dashboard/dist/assets/{shared-P-W1OYQ6.js → shared-CZVD0MJD.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/artifacts.js +160 -0
  17. package/dist/agent/images.js +51 -24
  18. package/dist/agent/index.js +4 -2
  19. package/dist/agent/mcp/tools/workspace.js +4 -3
  20. package/dist/bot/bot.js +83 -4
  21. package/dist/bot/commands.js +48 -2
  22. package/dist/bot/menu.js +1 -0
  23. package/dist/bot/session-hub.js +1 -1
  24. package/dist/channels/dingtalk/bot.js +9 -1
  25. package/dist/channels/discord/bot.js +9 -1
  26. package/dist/channels/feishu/bot.js +8 -1
  27. package/dist/channels/slack/bot.js +9 -1
  28. package/dist/channels/telegram/bot.js +8 -1
  29. package/dist/channels/wecom/bot.js +9 -1
  30. package/dist/channels/weixin/bot.js +9 -1
  31. package/dist/cli/main.js +1 -0
  32. package/dist/dashboard/routes/sessions.js +108 -27
  33. package/package.json +2 -1
  34. package/dashboard/dist/assets/SessionPanel-BVC7kwlX.js +0 -1
  35. package/dashboard/dist/assets/index-Dw3ty4QY.js +0 -23
  36. package/dashboard/dist/assets/index-FD86DEDF.css +0 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * artifacts.ts — delivered-artifact manifest (the single source of truth for
3
+ * "files the agent handed to the user during a session").
4
+ *
5
+ * When the agent calls `im_send_file`, the bot materializes the file into the
6
+ * session attachments dir and appends a record here. IM channels additionally
7
+ * push the bytes to their chat; the dashboard serves the materialized copy over
8
+ * HTTP and renders it. Recording is terminal-agnostic, so a session watched
9
+ * from BOTH a chat and the dashboard shows the same deliveries on either side,
10
+ * and they survive a page reload / workspace cleanup.
11
+ *
12
+ * The manifest lives next to the MCP-buffered image attachments
13
+ * (`sessionAttachmentsDir`), which is already an allowlist root for the
14
+ * dashboard attachment endpoint — so materialized artifacts are servable with
15
+ * no new trust boundary.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { sessionAttachmentsDir } from './images.js';
20
+ import { agentWarn } from './utils.js';
21
+ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
22
+ const MIME_BY_EXT = {
23
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
24
+ '.webp': 'image/webp', '.svg': 'image/svg+xml',
25
+ '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
26
+ '.csv': 'text/csv; charset=utf-8', '.json': 'application/json', '.xml': 'application/xml',
27
+ '.html': 'text/html; charset=utf-8', '.zip': 'application/zip', '.gz': 'application/gzip',
28
+ '.tar': 'application/x-tar', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
29
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.log': 'text/plain; charset=utf-8',
30
+ };
31
+ /** Best-effort MIME for a delivered artifact; `application/octet-stream` when unknown. */
32
+ export function mimeForArtifact(filePath) {
33
+ return MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
34
+ }
35
+ function manifestPath(agent, sessionId) {
36
+ return path.join(sessionAttachmentsDir(agent, sessionId), 'delivered.jsonl');
37
+ }
38
+ function deliveredDir(agent, sessionId) {
39
+ return path.join(sessionAttachmentsDir(agent, sessionId), 'delivered');
40
+ }
41
+ function sanitizeName(name) {
42
+ return (name || 'file').replace(/[/\\\0]+/g, '_').replace(/^\.+/, '').slice(0, 200) || 'file';
43
+ }
44
+ /**
45
+ * Materialize `srcPath` into the session's delivered-artifacts dir so it is
46
+ * (a) servable by the dashboard attachment endpoint (the dir is an allowlist
47
+ * root) and (b) durable across workspace cleanup. Hardlinks when possible,
48
+ * falling back to a copy across filesystems. The on-disk name is stamped to
49
+ * avoid collisions; the pristine basename travels in the manifest.
50
+ */
51
+ export function stageDeliveredArtifact(agent, sessionId, srcPath) {
52
+ const fileName = sanitizeName(path.basename(srcPath));
53
+ const stat = fs.statSync(srcPath);
54
+ const dir = deliveredDir(agent, sessionId);
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ const stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
57
+ const dest = path.join(dir, `${stamp}-${fileName}`);
58
+ try {
59
+ fs.linkSync(srcPath, dest);
60
+ }
61
+ catch {
62
+ fs.copyFileSync(srcPath, dest);
63
+ }
64
+ return { path: dest, fileName, fileSize: stat.size, fileMime: mimeForArtifact(fileName) };
65
+ }
66
+ /** Append a record to the session's delivered-artifact manifest. */
67
+ export function recordDeliveredArtifact(agent, sessionId, entry) {
68
+ try {
69
+ const file = manifestPath(agent, sessionId);
70
+ fs.mkdirSync(path.dirname(file), { recursive: true });
71
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
72
+ }
73
+ catch (e) {
74
+ agentWarn(`[artifacts] record failed: ${e?.message || e}`);
75
+ }
76
+ }
77
+ /**
78
+ * Materialize + record a delivered artifact in one step. Best-effort: returns
79
+ * the stored record (with the materialized path), or null on failure — delivery
80
+ * must never crash the stream.
81
+ */
82
+ export function deliverArtifact(agent, sessionId, srcPath, opts) {
83
+ try {
84
+ const staged = stageDeliveredArtifact(agent, sessionId, srcPath);
85
+ const record = {
86
+ ts: Date.now(),
87
+ ...(opts.taskId ? { taskId: opts.taskId } : {}),
88
+ path: staged.path,
89
+ fileName: staged.fileName,
90
+ fileMime: staged.fileMime,
91
+ fileSize: staged.fileSize,
92
+ kind: opts.kind,
93
+ ...(opts.caption ? { caption: opts.caption } : {}),
94
+ };
95
+ recordDeliveredArtifact(agent, sessionId, record);
96
+ return record;
97
+ }
98
+ catch (e) {
99
+ agentWarn(`[artifacts] deliver failed for ${srcPath}: ${e?.message || e}`);
100
+ return null;
101
+ }
102
+ }
103
+ /** Read the delivered-artifact manifest for a session (tolerant of partial lines). */
104
+ export function readDeliveredArtifacts(agent, sessionId) {
105
+ const file = manifestPath(agent, sessionId);
106
+ let raw;
107
+ try {
108
+ raw = fs.readFileSync(file, 'utf-8');
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ const out = [];
114
+ for (const line of raw.split('\n')) {
115
+ const t = line.trim();
116
+ if (!t)
117
+ continue;
118
+ try {
119
+ const rec = JSON.parse(t);
120
+ if (rec && typeof rec.path === 'string' && rec.fileName)
121
+ out.push(rec);
122
+ }
123
+ catch { /* skip malformed line */ }
124
+ }
125
+ return out;
126
+ }
127
+ /**
128
+ * Project the delivered-artifact manifest into pre-transport MessageBlocks
129
+ * (content = `file://<abs>`). The dashboard read path rewrites these to
130
+ * attachment HTTP URLs via `rewriteAttachmentBlocksForTransport`. Records whose
131
+ * file no longer exists on disk are dropped.
132
+ */
133
+ export function deliveredArtifactBlocks(agent, sessionId) {
134
+ const blocks = [];
135
+ for (const a of readDeliveredArtifacts(agent, sessionId)) {
136
+ if (!fs.existsSync(a.path))
137
+ continue;
138
+ if (a.kind === 'photo' && PHOTO_EXTS.has(path.extname(a.fileName).toLowerCase())) {
139
+ blocks.push({
140
+ type: 'image',
141
+ content: `file://${a.path}`,
142
+ imagePath: a.path,
143
+ imageMime: a.fileMime,
144
+ ...(a.caption ? { imageCaption: a.caption } : {}),
145
+ });
146
+ }
147
+ else {
148
+ blocks.push({
149
+ type: 'file',
150
+ content: `file://${a.path}`,
151
+ filePath: a.path,
152
+ fileMime: a.fileMime,
153
+ fileName: a.fileName,
154
+ fileSize: a.fileSize,
155
+ ...(a.caption ? { fileCaption: a.caption } : {}),
156
+ });
157
+ }
158
+ }
159
+ return blocks;
160
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * images.ts — unified image pipeline for the agent layer.
2
+ * images.ts — unified image (and on-disk attachment) pipeline for the agent
3
+ * layer.
3
4
  *
4
5
  * Every driver produces image blocks through `attachAgentImage`, every IM
5
6
  * channel consumes them through `materializeImage`, and every dashboard
6
- * response routes large images through the attachment HTTP endpoint via
7
- * `rewriteImageBlocksForTransport`. The shape isolates *where the bytes live*
7
+ * response routes on-disk image/file blocks through the attachment HTTP
8
+ * endpoint via `rewriteAttachmentBlocksForTransport` (delivered non-image files
9
+ * — see agent/artifacts.ts — ride the same transport). The shape isolates
10
+ * *where the bytes live*
8
11
  * from *how a renderer wants to consume them*, so adding a new driver source
9
12
  * (Codex `image_generation_call`, Claude MCP `tool_result` image, Gemini Imagen,
10
13
  * future) or a new transport (a fourth IM channel, a CLI exporter, an OG-image
@@ -25,7 +28,7 @@
25
28
  * - `content` always carries a directly-renderable reference: a `data:` URL
26
29
  * for inline bytes, or an attachment HTTP URL for files on disk.
27
30
  * - Below `INLINE_THRESHOLD_BYTES`, drivers may inline as `data:`. Above the
28
- * threshold, `rewriteImageBlocksForTransport` substitutes a relative
31
+ * threshold, `rewriteAttachmentBlocksForTransport` substitutes a relative
29
32
  * `/api/sessions/:agent/:id/attachment?...` URL — keeps RichMessage
30
33
  * payloads small even when a session has many large images.
31
34
  * - IM channels prefer `imagePath` over decoding `content`, avoiding wasted
@@ -192,7 +195,7 @@ export function attachAgentImage(opts) {
192
195
  }
193
196
  else {
194
197
  // Sentinel: the transport layer rewrites this into an HTTP URL with the
195
- // right session context. Renderers that bypass `rewriteImageBlocksForTransport`
198
+ // right session context. Renderers that bypass `rewriteAttachmentBlocksForTransport`
196
199
  // see a `file://` URL and can still resolve it via `materializeImage` since
197
200
  // `imagePath` is also populated.
198
201
  content = `file://${abs}`;
@@ -321,29 +324,53 @@ export function decodeAttachmentPathParam(value) {
321
324
  return Buffer.from(normalized, 'base64').toString('utf-8');
322
325
  }
323
326
  /**
324
- * Rewrite a block array for transport: any image block whose `content` is a
325
- * `file://` sentinel becomes an attachment HTTP URL, and any inline `data:`
326
- * URL above the threshold is also promoted to an HTTP URL (keeping the
327
- * RichMessage payload compact even when a session has many large images).
327
+ * Build the dashboard attachment URL that serves `absPath` for a given session.
328
+ * Single source for the `/api/sessions/:agent/:id/attachment?p=…` shape so the
329
+ * transport rewrite, live stream snapshots, and any future caller stay in sync.
330
+ * `downloadName` is carried as an opaque `&n=` hint the endpoint uses for the
331
+ * Content-Disposition filename (the pristine basename, not the on-disk one).
332
+ */
333
+ export function attachmentUrl(agent, sessionId, absPath, opts = {}) {
334
+ const base = (opts.apiBase || '/api/sessions').replace(/\/+$/, '');
335
+ const encoded = encodePathForUrl(absPath);
336
+ const name = opts.downloadName ? `&n=${encodeURIComponent(opts.downloadName)}` : '';
337
+ return `${base}/${encodeURIComponent(agent)}/${encodeURIComponent(sessionId)}/attachment?p=${encoded}${name}`;
338
+ }
339
+ /**
340
+ * Rewrite a block array for transport. Image and file blocks whose `content`
341
+ * is a `file://` sentinel (or which carry an on-disk `imagePath`/`filePath`)
342
+ * become attachment HTTP URLs; inline `data:` image URLs are left untouched so
343
+ * the dashboard renders them directly. Keeps RichMessage payloads compact even
344
+ * when a session delivered many large artifacts.
328
345
  *
329
346
  * Pure: returns a new array; the input is not mutated.
330
347
  */
331
- export function rewriteImageBlocksForTransport(blocks, ctx) {
332
- const base = (ctx.apiBase || '/api/sessions').replace(/\/+$/, '');
348
+ export function rewriteAttachmentBlocksForTransport(blocks, ctx) {
333
349
  return blocks.map(block => {
334
- if (block.type !== 'image')
335
- return block;
336
- const sourcePath = block.imagePath
337
- || (block.content?.startsWith('file://') ? block.content.slice('file://'.length) : '');
338
- if (!sourcePath)
339
- return block;
340
- // Inline content under the threshold: leave the data URL alone — the
341
- // dashboard renders it directly, no extra request.
342
- if (block.content?.startsWith('data:'))
343
- return block;
344
- const encoded = encodePathForUrl(sourcePath);
345
- const url = `${base}/${encodeURIComponent(ctx.agent)}/${encodeURIComponent(ctx.sessionId)}/attachment?p=${encoded}`;
346
- return { ...block, content: url, imagePath: sourcePath };
350
+ if (block.type === 'image') {
351
+ const sourcePath = block.imagePath
352
+ || (block.content?.startsWith('file://') ? block.content.slice('file://'.length) : '');
353
+ if (!sourcePath)
354
+ return block;
355
+ // Inline content under the threshold: leave the data URL alone — the
356
+ // dashboard renders it directly, no extra request.
357
+ if (block.content?.startsWith('data:'))
358
+ return block;
359
+ const url = attachmentUrl(ctx.agent, ctx.sessionId, sourcePath, { apiBase: ctx.apiBase });
360
+ return { ...block, content: url, imagePath: sourcePath };
361
+ }
362
+ if (block.type === 'file') {
363
+ const sourcePath = block.filePath
364
+ || (block.content?.startsWith('file://') ? block.content.slice('file://'.length) : '');
365
+ if (!sourcePath)
366
+ return block;
367
+ const url = attachmentUrl(ctx.agent, ctx.sessionId, sourcePath, {
368
+ apiBase: ctx.apiBase,
369
+ downloadName: block.fileName || undefined,
370
+ });
371
+ return { ...block, content: url, filePath: sourcePath };
372
+ }
373
+ return block;
347
374
  });
348
375
  }
349
376
  // ---------------------------------------------------------------------------
@@ -18,8 +18,10 @@ import './drivers/codex.js';
18
18
  import './drivers/gemini.js';
19
19
  import './drivers/hermes.js';
20
20
  export { IMAGE_EXTS } from './types.js';
21
- // ── Re-export: image pipeline ──────────────────────────────────────────────
22
- export { attachAgentImage, attachInlineImage, materializeImage, rewriteImageBlocksForTransport, resolveAllowedAttachmentPath, allowedAttachmentRoots, decodeAttachmentPathParam, sessionAttachmentsDir, codexHome, } from './images.js';
21
+ // ── Re-export: attachment pipeline (images + delivered files) ───────────────
22
+ export { attachAgentImage, attachInlineImage, materializeImage, rewriteAttachmentBlocksForTransport, attachmentUrl, resolveAllowedAttachmentPath, allowedAttachmentRoots, decodeAttachmentPathParam, sessionAttachmentsDir, codexHome, } from './images.js';
23
+ // ── Re-export: delivered-artifact manifest ──────────────────────────────────
24
+ export { deliverArtifact, readDeliveredArtifacts, deliveredArtifactBlocks, mimeForArtifact, } from './artifacts.js';
23
25
  // ── Re-export: utilities ────────────────────────────────────────────────────
24
26
  export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
25
27
  // ── Re-export: session management ───────────────────────────────────────────
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * tools/workspace.ts — Workspace file tools.
3
3
  *
4
- * workspace_list_files — returns workspace path, staged files, and directory listing
5
- * workspace_send_filesends a file back to the IM chat
4
+ * im_list_files — returns workspace path, staged files, and directory listing
5
+ * im_send_filedelivers a file to the user via the active terminal
6
+ * (IM chat push and/or the dashboard attachment endpoint)
6
7
  */
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -28,7 +29,7 @@ const tools = [
28
29
  },
29
30
  {
30
31
  name: 'im_send_file',
31
- description: 'Send a file to the user in IM.',
32
+ description: 'Send a file to the user through the active terminal (IM chat or web dashboard). Use this to hand over screenshots, reports, archives, or generated assets — the file is delivered and stays retrievable even when the user is connected remotely. Prefer this over printing a local filesystem path, which a remote user cannot open.',
32
33
  inputSchema: {
33
34
  type: 'object',
34
35
  properties: {
package/dist/bot/bot.js CHANGED
@@ -7,7 +7,7 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { execSync, spawn } from 'node:child_process';
9
9
  import { getActiveUserConfig, loadWorkspaces, onUserConfigChange, resolveUserWorkdir, setUserWorkdir, updateUserConfig } from '../core/config/user-config.js';
10
- import { doStream, ensureManagedSession, findManagedThreadSession, getSessionStoredConfig, getUsage, initializeProjectSkills, listAgents, resolveAgentModels, resolveDefaultAgent, listSkills, stageSessionFiles, reconcileOrphanedRunningSessions, getAgentBoundModelId, setAgentBoundModelId, collapseSkillPrompt, readGoal, accountTurn, shouldContinueAfterTurn, renderContinuationPrompt, renderBudgetLimitPrompt, bumpContinuationCount, pauseGoal, resumeGoal, setGoal as setGoalState, clearGoal as clearGoalState, setCodexGoal, getCodexGoal, clearCodexGoal, pauseCodexGoal, resumeCodexGoal, getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalPrompt, isPendingSessionId, } from '../agent/index.js';
10
+ import { doStream, ensureManagedSession, findManagedThreadSession, getSessionStoredConfig, getUsage, initializeProjectSkills, listAgents, resolveAgentModels, resolveDefaultAgent, listSkills, stageSessionFiles, reconcileOrphanedRunningSessions, getAgentBoundModelId, setAgentBoundModelId, collapseSkillPrompt, readGoal, accountTurn, shouldContinueAfterTurn, renderContinuationPrompt, renderBudgetLimitPrompt, bumpContinuationCount, pauseGoal, resumeGoal, setGoal as setGoalState, clearGoal as clearGoalState, setCodexGoal, getCodexGoal, clearCodexGoal, pauseCodexGoal, resumeCodexGoal, getClaudeNativeGoal, buildClaudeSetGoalPrompt, buildClaudeClearGoalPrompt, deliverArtifact, attachmentUrl, isPendingSessionId, } from '../agent/index.js';
11
11
  import { compactForHandover, describeHandoverRef } from '../agent/handover.js';
12
12
  import { getActiveProfileId, setActiveProfile } from '../model/index.js';
13
13
  import { querySessions, querySessionTail, updateSession, } from './session-hub.js';
@@ -58,10 +58,14 @@ function appendExtraPrompt(base, extra) {
58
58
  return lhs;
59
59
  return `${lhs}\n\n${rhs}`;
60
60
  }
61
+ // NOTE: the `[Artifact Return]` header is a load-bearing marker — both
62
+ // `stripInjectedPrompts` (bot/streaming.ts) and Gemini's
63
+ // `GEMINI_SYSTEM_BLOCK_SENTINELS` key on it to strip this injected block from
64
+ // displayed user messages. Keep the token in sync if you ever change it.
61
65
  function buildMcpDeliveryPrompt() {
62
66
  return [
63
67
  '[Artifact Return]',
64
- 'This is an IM/chat conversation, so pay attention to the IM tools.',
68
+ 'To hand a file to the user — a screenshot, report, archive, generated asset, anything they asked you to "send" — call the `im_send_file` tool with the file path and a short caption. It is delivered through whatever terminal the user is on (an IM chat or the web dashboard) and stays retrievable even when they are connected remotely. Do NOT just print a local filesystem path: a remote user cannot open paths on this machine.',
65
69
  ].join('\n');
66
70
  }
67
71
  function buildClaudeAskUserPrompt() {
@@ -360,6 +364,14 @@ export class Bot {
360
364
  }
361
365
  break;
362
366
  }
367
+ case 'artifact': {
368
+ const snap = this.streamSnapshots.get(sessionKey);
369
+ if (snap) {
370
+ snap.artifacts = [...(snap.artifacts || []), event.artifact];
371
+ snap.updatedAt = now;
372
+ }
373
+ break;
374
+ }
363
375
  case 'done': {
364
376
  const prev = this.streamSnapshots.get(sessionKey);
365
377
  this.streamSnapshots.set(sessionKey, {
@@ -375,6 +387,7 @@ export class Bot {
375
387
  model: prev?.model ?? null,
376
388
  effort: prev?.effort ?? null,
377
389
  previewMeta: prev?.previewMeta ?? null,
390
+ artifacts: prev?.artifacts,
378
391
  startedAt: prev?.startedAt,
379
392
  queuedTaskIds: prev?.queuedTaskIds,
380
393
  updatedAt: now,
@@ -482,6 +495,58 @@ export class Bot {
482
495
  emitStreamCancelled(taskId, fallbackKey) {
483
496
  this.emitStream(this.liveSessionKey(taskId, fallbackKey), { type: 'cancelled', taskId });
484
497
  }
498
+ /**
499
+ * Wrap a per-turn `im_send_file` callback so every artifact delivery is
500
+ * recorded to the session's durable manifest (agent/artifacts.ts) and
501
+ * mirrored live into the running turn's snapshot — on top of whatever
502
+ * terminal-specific push the caller supplied (`inner`, e.g. an IM chat
503
+ * upload). The returned callback is always safe to register, so the tool is
504
+ * available for dashboard / headless turns that have no `inner` (the
505
+ * dashboard serves the recorded copy over HTTP). Recording is best-effort and
506
+ * never propagates an error back into the delivery result.
507
+ */
508
+ buildArtifactSendFile(agent, sessionKey, cs, inner) {
509
+ return async (filePath, sendOpts) => {
510
+ // Terminal-specific push first (IM chat). Dashboard / headless has no inner.
511
+ const result = inner ? await inner(filePath, sendOpts) : { ok: true };
512
+ if (!result.ok)
513
+ return result;
514
+ try {
515
+ // Resolve the freshest session id at delivery time — send_file fires
516
+ // late in a turn, after a pending→native promotion has normally already
517
+ // happened, so the manifest lands under the canonical id.
518
+ let sid = '';
519
+ if (sessionKey) {
520
+ const rt = this.getSessionRuntimeByKey(sessionKey, { allowAnyWorkdir: true });
521
+ if (rt?.sessionId)
522
+ sid = rt.sessionId;
523
+ }
524
+ if (!sid && typeof cs.sessionId === 'string')
525
+ sid = cs.sessionId;
526
+ if (!sid || isPendingSessionId(sid))
527
+ return result;
528
+ const kind = sendOpts?.kind === 'photo' ? 'photo' : 'document';
529
+ const record = deliverArtifact(agent, sid, filePath, { kind, caption: sendOpts?.caption });
530
+ if (record && sessionKey) {
531
+ this.emitStream(this.resolveSessionKey(sessionKey), {
532
+ type: 'artifact',
533
+ artifact: {
534
+ url: attachmentUrl(agent, sid, record.path, { downloadName: record.fileName }),
535
+ fileName: record.fileName,
536
+ fileSize: record.fileSize,
537
+ mime: record.fileMime,
538
+ kind: record.kind,
539
+ ...(record.caption ? { caption: record.caption } : {}),
540
+ },
541
+ });
542
+ }
543
+ }
544
+ catch (e) {
545
+ this.warn(`[runStream] artifact record failed: ${e?.message || e}`);
546
+ }
547
+ return result;
548
+ };
549
+ }
485
550
  keepAliveProc = null;
486
551
  keepAlivePulseTimer = null;
487
552
  sessionChains = new Map();
@@ -2376,7 +2441,21 @@ export class Bot {
2376
2441
  // falls back to the agent's in-memory flag (IM /mode) when unspecified.
2377
2442
  // Default off — never read from a persisted config default.
2378
2443
  const workflowEnabled = cs.agent === 'claude' && (extras?.workflowEnabled ?? this.claudeWorkflowEnabled);
2379
- const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(appendExtraPrompt(mcpSendFile ? buildMcpDeliveryPrompt() : '', onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
2444
+ // ── Artifact delivery (terminal-agnostic) ──
2445
+ // `im_send_file` is the single "hand a file to the user" verb. Whatever the
2446
+ // caller wired as `mcpSendFile` (an IM channel push, or nothing for a
2447
+ // dashboard / headless turn) is wrapped so every delivery ALSO records the
2448
+ // file into the session's delivered-artifact manifest — the durable SSOT
2449
+ // that survives reload + workspace cleanup and is servable over HTTP — and
2450
+ // surfaces it live in the running turn's snapshot for any watching
2451
+ // dashboard. Always provided, so the tool + delivery prompt light up even
2452
+ // when there is no IM channel (the dashboard is the delivery surface then).
2453
+ const deliverySessionKey = ('key' in cs && typeof cs.key === 'string') ? cs.key : null;
2454
+ const wrappedSendFile = this.buildArtifactSendFile(cs.agent, deliverySessionKey, cs, mcpSendFile);
2455
+ const mcpSystemPrompt = appendExtraPrompt(appendExtraPrompt(appendExtraPrompt(
2456
+ // Always-on: `wrappedSendFile` is provided for every turn (see above),
2457
+ // so artifact delivery is available regardless of terminal.
2458
+ buildMcpDeliveryPrompt(), onInteraction && cs.agent === 'claude' ? buildClaudeAskUserPrompt() : ''), buildBrowserAutomationPrompt(browserEnabled)), workflowEnabled ? buildWorkflowOptInPrompt() : '');
2380
2459
  // mcpSystemPrompt carries behaviour directives (use im_ask_user instead of
2381
2460
  // built-in AskUserQuestion, browser automation status, artifact delivery)
2382
2461
  // that must apply on every turn, not just the first — on resume the CLI
@@ -2433,7 +2512,7 @@ export class Bot {
2433
2512
  // a Profile is bound).
2434
2513
  hermesModel: cs.agent === 'hermes' && resolvedModel ? resolvedModel : undefined,
2435
2514
  // MCP bridge
2436
- mcpSendFile,
2515
+ mcpSendFile: wrappedSendFile,
2437
2516
  abortSignal,
2438
2517
  onInteraction,
2439
2518
  onSteerReady,
@@ -247,6 +247,52 @@ export async function getSessionsPageData(bot, chatId, page, pageSize = 5) {
247
247
  sessions: entries,
248
248
  };
249
249
  }
250
+ export async function getSessionsDigestData(bot, chatId, limit = 8) {
251
+ const pageData = await getSessionsPageData(bot, chatId, 0, Math.max(1, limit));
252
+ const entries = pageData.sessions.map((session, index) => ({
253
+ index: index + 1,
254
+ agent: session.agent,
255
+ title: session.title,
256
+ time: session.time,
257
+ runState: session.runState,
258
+ runDetail: session.runDetail,
259
+ isCurrent: session.isCurrent,
260
+ sessionKey: session.key,
261
+ }));
262
+ return {
263
+ workspaceName: pageData.workspaceName,
264
+ agentTotals: pageData.agentTotals,
265
+ total: pageData.total,
266
+ entries,
267
+ };
268
+ }
269
+ export function formatSessionsDigestText(data) {
270
+ if (!data.entries.length) {
271
+ return data.workspaceName
272
+ ? `No sessions in ${data.workspaceName} yet. Send a message to start.`
273
+ : 'No sessions yet. Send a message to start.';
274
+ }
275
+ const agentBits = Object.entries(data.agentTotals)
276
+ .map(([agent, count]) => `${agent}×${count}`)
277
+ .join(' · ');
278
+ const lines = [
279
+ `Session digest — ${data.workspaceName || 'workspace'} (${data.total} total${agentBits ? ` · ${agentBits}` : ''})`,
280
+ '',
281
+ ];
282
+ for (const entry of data.entries) {
283
+ const flags = [
284
+ entry.isCurrent ? 'current' : null,
285
+ entry.runState === 'running' ? 'running' : null,
286
+ entry.runState === 'incomplete' ? 'unfinished' : null,
287
+ ].filter(Boolean).join(', ');
288
+ const flagSuffix = flags ? ` [${flags}]` : '';
289
+ lines.push(`${entry.index}. ${entry.agent} · ${entry.title}${flagSuffix}`);
290
+ const detail = entry.runDetail ? ` · ${entry.runDetail}` : '';
291
+ lines.push(` ${entry.time}${detail}`);
292
+ }
293
+ lines.push('', 'Switch: /sessions <#> · Browse: /sessions');
294
+ return lines.join('\n');
295
+ }
250
296
  export function extractLastSessionTurn(messages) {
251
297
  if (!messages.length)
252
298
  return null;
@@ -479,10 +525,10 @@ export function resolveSkillPrompt(bot, chatId, cmd, args) {
479
525
  const paths = getProjectSkillPaths(wd, skill.name);
480
526
  const skillFile = paths.claudeSkillFile || paths.sharedSkillFile || paths.agentsSkillFile;
481
527
  if (skillFile) {
482
- prompt = `${workdirHint}Read the skill definition at \`${skillFile}\` and execute the instructions defined there.${suffix}`;
528
+ prompt = `${workdirHint}Read the skill definition at \`${relSkillPath(wd, skillFile)}\` and execute the instructions defined there.${suffix}`;
483
529
  }
484
530
  else {
485
- const fallbackPath = `${wd}/.pikiloom/skills/${skill.name}/SKILL.md`;
531
+ const fallbackPath = relSkillPath(wd, path.join(wd, '.pikiloom', 'skills', skill.name, 'SKILL.md'));
486
532
  prompt = `${workdirHint}Read the skill definition at \`${fallbackPath}\` and execute the instructions defined there.${suffix}`;
487
533
  }
488
534
  return { prompt, skillName: skill.name };
package/dist/bot/menu.js CHANGED
@@ -31,6 +31,7 @@ export function indexSkillsByCommand(skills) {
31
31
  export function buildDefaultMenuCommands(agentCount, skills = []) {
32
32
  const commands = [
33
33
  { command: 'sessions', description: 'Switch sessions' },
34
+ { command: 'digest', description: 'Recent session digest' },
34
35
  ];
35
36
  if (agentCount > 1) {
36
37
  commands.push({ command: 'agents', description: 'Switch agents' });
@@ -121,7 +121,7 @@ function imageBlocksFromManagedRecord(record) {
121
121
  const abs = path.isAbsolute(rel) ? rel : path.join(record.workspacePath, rel);
122
122
  blocks.push({
123
123
  type: 'image',
124
- // `file://` sentinel — `rewriteImageBlocksForTransport` (dashboard
124
+ // `file://` sentinel — `rewriteAttachmentBlocksForTransport` (dashboard
125
125
  // response layer) converts it to a proper /attachment URL.
126
126
  content: `file://${abs}`,
127
127
  imagePath: abs,
@@ -9,7 +9,7 @@ import { BOT_SHUTDOWN_FORCE_EXIT_MS, buildSessionTaskId } from '../../bot/orches
9
9
  import { shutdownAllDrivers } from '../../agent/driver.js';
10
10
  import { expandTilde } from '../../core/platform.js';
11
11
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
12
- import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getStartData, getWorkspacesData, } from '../../bot/commands.js';
12
+ import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, } from '../../bot/commands.js';
13
13
  import { DingtalkChannel } from './channel.js';
14
14
  import { getActiveUserConfig } from '../../core/config/user-config.js';
15
15
  const SHUTDOWN_EXIT_CODE = {
@@ -127,6 +127,7 @@ export class DingtalkBot extends Bot {
127
127
  '/switch [path] - Change workdir',
128
128
  '/workspaces [#] - Pick saved workspace',
129
129
  '/sessions [new|#] - List/switch sessions',
130
+ '/digest - Recent session digest',
130
131
  '/skills - List project skills',
131
132
  '/stop - Stop current task',
132
133
  '/restart - Restart pikiloom',
@@ -160,6 +161,9 @@ export class DingtalkBot extends Bot {
160
161
  case 'sessions':
161
162
  await this.cmdSessions(ctx, args);
162
163
  return true;
164
+ case 'digest':
165
+ await this.cmdDigest(ctx);
166
+ return true;
163
167
  case 'skills':
164
168
  await this.cmdSkills(ctx);
165
169
  return true;
@@ -187,6 +191,10 @@ export class DingtalkBot extends Bot {
187
191
  lines.push('', 'Ready. Send a message to start.');
188
192
  await ctx.reply(lines.join('\n'));
189
193
  }
194
+ async cmdDigest(ctx) {
195
+ const data = await getSessionsDigestData(this, ctx.chatId);
196
+ await ctx.reply(formatSessionsDigestText(data));
197
+ }
190
198
  async cmdStatus(ctx) {
191
199
  const d = await getStatusDataAsync(this, ctx.chatId);
192
200
  const gitLine = formatGitStatusLine(d.git);
@@ -13,7 +13,7 @@ import { BOT_SHUTDOWN_FORCE_EXIT_MS, buildSessionTaskId } from '../../bot/orches
13
13
  import { shutdownAllDrivers } from '../../agent/driver.js';
14
14
  import { expandTilde } from '../../core/platform.js';
15
15
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
16
- import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getStartData, getWorkspacesData, } from '../../bot/commands.js';
16
+ import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, } from '../../bot/commands.js';
17
17
  import { DiscordChannel } from './channel.js';
18
18
  import { getActiveUserConfig } from '../../core/config/user-config.js';
19
19
  const SHUTDOWN_EXIT_CODE = {
@@ -123,6 +123,7 @@ export class DiscordBot extends Bot {
123
123
  '/switch [path] - Change workdir',
124
124
  '/workspaces [#] - Pick saved workspace',
125
125
  '/sessions [new|#] - List/switch sessions',
126
+ '/digest - Recent session digest',
126
127
  '/skills - List project skills',
127
128
  '/stop - Stop current task',
128
129
  '/restart - Restart pikiloom',
@@ -156,6 +157,9 @@ export class DiscordBot extends Bot {
156
157
  case 'sessions':
157
158
  await this.cmdSessions(ctx, args);
158
159
  return true;
160
+ case 'digest':
161
+ await this.cmdDigest(ctx);
162
+ return true;
159
163
  case 'skills':
160
164
  await this.cmdSkills(ctx);
161
165
  return true;
@@ -183,6 +187,10 @@ export class DiscordBot extends Bot {
183
187
  lines.push('', 'Ready. Send a message to start.');
184
188
  await ctx.reply(lines.join('\n'));
185
189
  }
190
+ async cmdDigest(ctx) {
191
+ const data = await getSessionsDigestData(this, ctx.chatId);
192
+ await ctx.reply(formatSessionsDigestText(data));
193
+ }
186
194
  async cmdStatus(ctx) {
187
195
  const d = await getStatusDataAsync(this, ctx.chatId);
188
196
  const gitLine = formatGitStatusLine(d.git);
@@ -15,7 +15,7 @@ import { stageSessionFiles, } from '../../agent/index.js';
15
15
  import { shutdownAllDrivers } from '../../agent/driver.js';
16
16
  import { expandTilde } from '../../core/platform.js';
17
17
  import { SKILL_CMD_PREFIX, } from '../../bot/menu.js';
18
- import { getStartData, getSessionsPageData, getModelsListData, getSessionTurnPreviewData, getStatusDataAsync, getHostDataSync, getWorkspacesData, resolveSkillPrompt, handleGoalCommand, } from '../../bot/commands.js';
18
+ import { getStartData, getSessionsPageData, getModelsListData, getSessionTurnPreviewData, getStatusDataAsync, getHostDataSync, getWorkspacesData, resolveSkillPrompt, handleGoalCommand, getSessionsDigestData, formatSessionsDigestText, } from '../../bot/commands.js';
19
19
  import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from '../../bot/command-ui.js';
20
20
  import { LivePreview } from '../telegram/live-preview.js';
21
21
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
@@ -355,6 +355,10 @@ export class FeishuBot extends Bot {
355
355
  }
356
356
  await this.sendCommandView(ctx, await buildSessionsCommandView(this, ctx.chatId, 0, this.sessionsPageSize));
357
357
  }
358
+ async cmdDigest(ctx) {
359
+ const data = await getSessionsDigestData(this, ctx.chatId);
360
+ await ctx.reply(formatSessionsDigestText(data));
361
+ }
358
362
  async cmdStatus(ctx) {
359
363
  const d = await getStatusDataAsync(this, ctx.chatId);
360
364
  await ctx.reply(renderStatus(d));
@@ -962,6 +966,9 @@ export class FeishuBot extends Bot {
962
966
  case 'sessions':
963
967
  await this.cmdSessions(ctx, args);
964
968
  return;
969
+ case 'digest':
970
+ await this.cmdDigest(ctx);
971
+ return;
965
972
  case 'agents':
966
973
  await this.cmdAgents(ctx, args);
967
974
  return;