pikiloop 0.4.0
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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bot-commands.ts — channel-agnostic command data layer.
|
|
3
|
+
*
|
|
4
|
+
* Each function returns structured data objects that any IM renderer can consume.
|
|
5
|
+
* No rendering, no HTML, no platform-specific formatting.
|
|
6
|
+
*
|
|
7
|
+
* Usage from a channel-specific bot (e.g. bot-telegram.ts, bot-feishu.ts):
|
|
8
|
+
* const data = await getSessionsPageData(bot, chatId, 0);
|
|
9
|
+
* const rendered = renderSessionsPage(data); // channel-specific renderer
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import { fmtTokens, fmtUptime, fmtBytes } from './bot.js';
|
|
14
|
+
import { getProjectSkillPaths, normalizeClaudeModelId, sessionListDisplayTitle, listAllMcpExtensions, listSkills as listAllSkills, } from '../agent/index.js';
|
|
15
|
+
import { getDriver } from '../agent/driver.js';
|
|
16
|
+
import { getActiveProfile, getProvider } from '../model/index.js';
|
|
17
|
+
import { buildWelcomeIntro, buildSkillCommandName, indexSkillsByCommand, SKILL_CMD_PREFIX } from './menu.js';
|
|
18
|
+
import { buildBotMenuState } from './orchestration.js';
|
|
19
|
+
import { summarizePromptForStatus } from './streaming.js';
|
|
20
|
+
import { getSessionStatusForChat } from './session-status.js';
|
|
21
|
+
import { loadWorkspaces } from '../core/config/user-config.js';
|
|
22
|
+
import { VERSION } from '../core/version.js';
|
|
23
|
+
import { readGitStatus } from '../core/git.js';
|
|
24
|
+
export function getStartData(bot, chatId) {
|
|
25
|
+
const cs = bot.chat(chatId);
|
|
26
|
+
const intro = buildWelcomeIntro(VERSION);
|
|
27
|
+
const commands = buildBotMenuState(bot).commands;
|
|
28
|
+
const res = bot.fetchAgents();
|
|
29
|
+
const agentDetails = res.agents
|
|
30
|
+
.filter(a => a.installed)
|
|
31
|
+
.map(a => ({
|
|
32
|
+
agent: a.agent,
|
|
33
|
+
model: bot.modelForAgent(a.agent) || '(default)',
|
|
34
|
+
effort: bot.effortSelectionForAgent(a.agent),
|
|
35
|
+
}));
|
|
36
|
+
return {
|
|
37
|
+
...intro,
|
|
38
|
+
agent: cs.agent,
|
|
39
|
+
workdir: bot.chatWorkdir(chatId),
|
|
40
|
+
agentDetails,
|
|
41
|
+
commands,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function getWorkspacesData(bot, chatId) {
|
|
45
|
+
const currentWorkdir = path.resolve(bot.chatWorkdir(chatId));
|
|
46
|
+
const entries = loadWorkspaces();
|
|
47
|
+
const workspaces = entries
|
|
48
|
+
.slice()
|
|
49
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
50
|
+
.map(w => {
|
|
51
|
+
const resolved = path.resolve(w.path);
|
|
52
|
+
let exists = false;
|
|
53
|
+
try {
|
|
54
|
+
exists = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
exists = false;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
path: resolved,
|
|
61
|
+
name: w.name || path.basename(resolved),
|
|
62
|
+
isCurrent: resolved === currentWorkdir,
|
|
63
|
+
exists,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
return { currentWorkdir, workspaces };
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Goal — channel-agnostic /goal command dispatch
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
/**
|
|
72
|
+
* Handle /goal <args> for a chat. Returns a human-readable status line for
|
|
73
|
+
* the IM renderer to send back. Returns null when there is no active session
|
|
74
|
+
* for the chat (caller renders its own "pick a session first" message).
|
|
75
|
+
*
|
|
76
|
+
* Per-agent routing:
|
|
77
|
+
* - codex → native `thread/goal/*` RPC (state machine + budget + pause/resume)
|
|
78
|
+
* - claude → native `/goal <condition>` slash command (Stop hook continuation,
|
|
79
|
+
* auto-clear on completion, no budget / no pause/resume)
|
|
80
|
+
* - others → pikiloop's portable goal.json with continuation injection
|
|
81
|
+
*/
|
|
82
|
+
export async function handleGoalCommand(bot, chatId, rawArgs) {
|
|
83
|
+
const session = bot.selectedSession(chatId);
|
|
84
|
+
if (!session || !session.sessionId)
|
|
85
|
+
return null;
|
|
86
|
+
const args = rawArgs.trim();
|
|
87
|
+
const workdir = session.workdir;
|
|
88
|
+
const agent = session.agent;
|
|
89
|
+
const sessionId = session.sessionId;
|
|
90
|
+
if (!args) {
|
|
91
|
+
const goal = await bot.getSessionGoal(workdir, agent, sessionId);
|
|
92
|
+
return formatGoalStatusLine(goal, agent);
|
|
93
|
+
}
|
|
94
|
+
const lower = args.toLowerCase();
|
|
95
|
+
try {
|
|
96
|
+
if (lower === 'pause') {
|
|
97
|
+
const goal = await bot.pauseSessionGoal(workdir, agent, sessionId);
|
|
98
|
+
if (!goal)
|
|
99
|
+
return 'No goal set for this session.';
|
|
100
|
+
return `Paused goal: ${truncate(goal.objective, 80)}`;
|
|
101
|
+
}
|
|
102
|
+
if (lower === 'resume') {
|
|
103
|
+
const goal = await bot.resumeSessionGoal(workdir, agent, sessionId, { chatId });
|
|
104
|
+
if (!goal)
|
|
105
|
+
return 'No goal to resume.';
|
|
106
|
+
if (goal.status !== 'active')
|
|
107
|
+
return `Cannot resume goal (status: ${goal.status}).`;
|
|
108
|
+
return `Resumed goal: ${truncate(goal.objective, 80)}`;
|
|
109
|
+
}
|
|
110
|
+
if (lower === 'clear' || lower === 'cancel' || lower === 'stop') {
|
|
111
|
+
await bot.clearSessionGoal(workdir, agent, sessionId, { chatId });
|
|
112
|
+
return agent === 'claude'
|
|
113
|
+
? 'Submitted `/goal clear` to claude. (Native /goal auto-clears once the condition is met, so this is only needed to stop early.)'
|
|
114
|
+
: 'Cleared goal.';
|
|
115
|
+
}
|
|
116
|
+
const { objective, tokenBudget } = parseObjective(args);
|
|
117
|
+
if (!objective)
|
|
118
|
+
return 'Usage: /goal <objective> (or pause / resume / clear)';
|
|
119
|
+
if (agent === 'claude' && tokenBudget != null) {
|
|
120
|
+
return 'Claude native /goal does not support `budget=N` — drop the budget prefix. (Use a codex session if you need a token budget.)';
|
|
121
|
+
}
|
|
122
|
+
const goal = await bot.setSessionGoal(workdir, agent, sessionId, {
|
|
123
|
+
objective,
|
|
124
|
+
tokenBudget,
|
|
125
|
+
chatId,
|
|
126
|
+
});
|
|
127
|
+
const budgetLabel = goal.tokenBudget != null ? `, budget ${goal.tokenBudget} tokens` : '';
|
|
128
|
+
if (agent === 'codex') {
|
|
129
|
+
return [
|
|
130
|
+
`Goal set (codex native)${budgetLabel}: ${truncate(goal.objective, 120)}`,
|
|
131
|
+
'Send any message to trigger codex\'s native continuation loop. Each message resumes the thread and codex audits / continues until it marks the goal complete or hits the budget.',
|
|
132
|
+
].join('\n');
|
|
133
|
+
}
|
|
134
|
+
if (agent === 'claude') {
|
|
135
|
+
return [
|
|
136
|
+
`Goal set (claude native): ${truncate(goal.objective, 120)}`,
|
|
137
|
+
'Claude\'s in-process Stop hook keeps working until a Haiku judge confirms the condition is met, then auto-clears. Send `/goal clear` to stop early; `/goal` to inspect.',
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
return `Goal set${budgetLabel}: ${truncate(goal.objective, 120)}\nThe agent will keep working until it audits the objective complete${goal.tokenBudget != null ? ' or exhausts the budget' : ''}.`;
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
return `Failed: ${e?.message || e}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function formatGoalStatusLine(goal, agent) {
|
|
147
|
+
if (!goal)
|
|
148
|
+
return 'No goal set for this session. Use `/goal <objective>` to set one.';
|
|
149
|
+
if (goal.source === 'claude') {
|
|
150
|
+
return [
|
|
151
|
+
`Goal: ${truncate(goal.objective, 200)}`,
|
|
152
|
+
`Status: ${goal.status} · claude native (Stop hook, auto-clears on completion)`,
|
|
153
|
+
].join('\n');
|
|
154
|
+
}
|
|
155
|
+
const budget = goal.tokenBudget != null
|
|
156
|
+
? `${goal.tokensUsed}/${goal.tokenBudget} tokens`
|
|
157
|
+
: `${goal.tokensUsed} tokens (no budget)`;
|
|
158
|
+
const continuations = goal.continuationCount != null ? ` · ${goal.continuationCount} continuations` : '';
|
|
159
|
+
const engine = goal.source === 'codex' ? ' · codex native' : '';
|
|
160
|
+
return [
|
|
161
|
+
`Goal: ${truncate(goal.objective, 200)}`,
|
|
162
|
+
`Status: ${goal.status} · ${budget}${continuations} · ${goal.timeUsedSeconds}s elapsed${engine}`,
|
|
163
|
+
].join('\n');
|
|
164
|
+
}
|
|
165
|
+
function parseObjective(args) {
|
|
166
|
+
const m = args.match(/^budget=(\d+)\s+(.+)$/i);
|
|
167
|
+
if (m) {
|
|
168
|
+
const tokenBudget = Number.parseInt(m[1], 10);
|
|
169
|
+
return { objective: m[2].trim(), tokenBudget: Number.isFinite(tokenBudget) && tokenBudget > 0 ? tokenBudget : null };
|
|
170
|
+
}
|
|
171
|
+
return { objective: args, tokenBudget: null };
|
|
172
|
+
}
|
|
173
|
+
function truncate(text, max) {
|
|
174
|
+
if (text.length <= max)
|
|
175
|
+
return text;
|
|
176
|
+
return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
177
|
+
}
|
|
178
|
+
export function summarizeSessionRun(session) {
|
|
179
|
+
if (session.running || session.runState === 'running') {
|
|
180
|
+
return {
|
|
181
|
+
state: 'running',
|
|
182
|
+
shortLabel: 'running',
|
|
183
|
+
noticeDetail: 'Status: running',
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (session.runState === 'incomplete') {
|
|
187
|
+
const detail = String(session.runDetail || '').trim();
|
|
188
|
+
return {
|
|
189
|
+
state: 'incomplete',
|
|
190
|
+
shortLabel: 'unfinished',
|
|
191
|
+
noticeDetail: detail ? `Status: unfinished · ${detail}` : 'Status: unfinished',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
state: 'completed',
|
|
196
|
+
shortLabel: 'done',
|
|
197
|
+
noticeDetail: 'Status: completed',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function getSessionsPageData(bot, chatId, page, pageSize = 5) {
|
|
201
|
+
const cs = bot.chat(chatId);
|
|
202
|
+
// Workspace-wide: drop the cs.agent filter so the list matches what the
|
|
203
|
+
// dashboard shows for this workspace (all installed agents, sorted by
|
|
204
|
+
// most-recent activity).
|
|
205
|
+
const res = await bot.fetchSessions(undefined, bot.chatWorkdir(chatId));
|
|
206
|
+
const sessions = res.ok ? res.sessions : [];
|
|
207
|
+
const total = sessions.length;
|
|
208
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
209
|
+
const pg = Math.max(0, Math.min(page, totalPages - 1));
|
|
210
|
+
const slice = sessions.slice(pg * pageSize, (pg + 1) * pageSize);
|
|
211
|
+
const agentTotals = {};
|
|
212
|
+
for (const s of sessions)
|
|
213
|
+
agentTotals[s.agent] = (agentTotals[s.agent] || 0) + 1;
|
|
214
|
+
const entries = [];
|
|
215
|
+
for (const s of slice) {
|
|
216
|
+
const sessionKey = s.sessionId || '';
|
|
217
|
+
if (!sessionKey)
|
|
218
|
+
continue;
|
|
219
|
+
const status = getSessionStatusForChat(bot, cs, s);
|
|
220
|
+
const runSummary = summarizeSessionRun({
|
|
221
|
+
running: status.isRunning,
|
|
222
|
+
runState: status.isRunning ? 'running' : s.runState,
|
|
223
|
+
runDetail: s.runDetail,
|
|
224
|
+
});
|
|
225
|
+
const displayText = sessionListDisplayTitle(s);
|
|
226
|
+
const title = displayText ? displayText.replace(/\n/g, ' ').slice(0, 28) : sessionKey.slice(0, 28);
|
|
227
|
+
const time = s.createdAt
|
|
228
|
+
? new Date(s.createdAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
229
|
+
: '?';
|
|
230
|
+
entries.push({
|
|
231
|
+
key: sessionKey,
|
|
232
|
+
agent: s.agent,
|
|
233
|
+
title,
|
|
234
|
+
time: `${time} · ${runSummary.shortLabel}`,
|
|
235
|
+
isCurrent: status.isCurrent,
|
|
236
|
+
isRunning: status.isRunning,
|
|
237
|
+
runState: runSummary.state,
|
|
238
|
+
runDetail: s.runDetail,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
workspaceName: res.workspaceName || '',
|
|
243
|
+
agentTotals,
|
|
244
|
+
total,
|
|
245
|
+
page: pg,
|
|
246
|
+
totalPages,
|
|
247
|
+
sessions: entries,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export function extractLastSessionTurn(messages) {
|
|
251
|
+
if (!messages.length)
|
|
252
|
+
return null;
|
|
253
|
+
let lastUserIndex = -1;
|
|
254
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
255
|
+
if (messages[i].role === 'user') {
|
|
256
|
+
lastUserIndex = i;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const userText = String(lastUserIndex >= 0 ? messages[lastUserIndex].text : '').trim() || null;
|
|
261
|
+
const assistantTexts = [];
|
|
262
|
+
for (let i = lastUserIndex >= 0 ? lastUserIndex + 1 : 0; i < messages.length; i++) {
|
|
263
|
+
if (messages[i].role === 'assistant' && messages[i].text)
|
|
264
|
+
assistantTexts.push(messages[i].text);
|
|
265
|
+
}
|
|
266
|
+
const assistantText = assistantTexts.join('\n\n').trim() || null;
|
|
267
|
+
if (!userText && !assistantText)
|
|
268
|
+
return null;
|
|
269
|
+
return { userText, assistantText };
|
|
270
|
+
}
|
|
271
|
+
export async function getSessionTurnPreviewData(bot, agent, sessionId, limit = 50, workdir) {
|
|
272
|
+
if (!sessionId)
|
|
273
|
+
return null;
|
|
274
|
+
const tail = await bot.fetchSessionTail(agent, sessionId, limit, workdir);
|
|
275
|
+
if (!tail.ok || !tail.messages.length)
|
|
276
|
+
return null;
|
|
277
|
+
return extractLastSessionTurn(tail.messages);
|
|
278
|
+
}
|
|
279
|
+
const AGENT_LABEL_OVERRIDES = {
|
|
280
|
+
claude: 'Claude Code',
|
|
281
|
+
codex: 'Codex',
|
|
282
|
+
gemini: 'Gemini CLI',
|
|
283
|
+
hermes: 'Hermes',
|
|
284
|
+
};
|
|
285
|
+
function agentDisplayLabel(agentId) {
|
|
286
|
+
return AGENT_LABEL_OVERRIDES[agentId]
|
|
287
|
+
|| agentId.charAt(0).toUpperCase() + agentId.slice(1);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Pull the first semver-ish token out of a CLI `--version` string. Falls back
|
|
291
|
+
* to the original string when no semver is present.
|
|
292
|
+
*
|
|
293
|
+
* "2.1.132 (Claude Code v2.1.132)" → "2.1.132"
|
|
294
|
+
* "Hermes Agent v0.12.0 (2026.4.30)" → "0.12.0"
|
|
295
|
+
* "codex-cli 0.128.0" → "0.128.0"
|
|
296
|
+
*/
|
|
297
|
+
function shortVersion(raw) {
|
|
298
|
+
if (!raw)
|
|
299
|
+
return null;
|
|
300
|
+
const m = raw.match(/\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/);
|
|
301
|
+
return m ? m[0] : raw.trim();
|
|
302
|
+
}
|
|
303
|
+
export function getAgentsListData(bot, chatId) {
|
|
304
|
+
const cs = bot.chat(chatId);
|
|
305
|
+
const res = bot.fetchAgents();
|
|
306
|
+
return {
|
|
307
|
+
currentAgent: cs.agent,
|
|
308
|
+
agents: res.agents.map(a => {
|
|
309
|
+
const profile = getActiveProfile(a.agent);
|
|
310
|
+
const provider = profile ? getProvider(profile.providerId) : null;
|
|
311
|
+
return {
|
|
312
|
+
agent: a.agent,
|
|
313
|
+
label: agentDisplayLabel(a.agent),
|
|
314
|
+
installed: a.installed,
|
|
315
|
+
version: a.version ?? null,
|
|
316
|
+
versionShort: shortVersion(a.version ?? null),
|
|
317
|
+
path: a.path ?? null,
|
|
318
|
+
isCurrent: a.agent === cs.agent,
|
|
319
|
+
boundProvider: provider?.name ?? null,
|
|
320
|
+
boundModel: profile?.modelId ?? null,
|
|
321
|
+
};
|
|
322
|
+
}),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export function getSkillsListData(bot, chatId) {
|
|
326
|
+
const cs = bot.chat(chatId);
|
|
327
|
+
const skills = bot.fetchSkills(bot.chatWorkdir(chatId)).skills
|
|
328
|
+
.map(skill => {
|
|
329
|
+
const command = buildSkillCommandName(skill.name);
|
|
330
|
+
if (!command)
|
|
331
|
+
return null;
|
|
332
|
+
return {
|
|
333
|
+
name: skill.name,
|
|
334
|
+
label: skill.label || skill.name.charAt(0).toUpperCase() + skill.name.slice(1),
|
|
335
|
+
description: skill.description,
|
|
336
|
+
command,
|
|
337
|
+
source: skill.source,
|
|
338
|
+
};
|
|
339
|
+
})
|
|
340
|
+
.filter((skill) => !!skill);
|
|
341
|
+
return { agent: cs.agent, workdir: bot.chatWorkdir(chatId), skills };
|
|
342
|
+
}
|
|
343
|
+
function claudeModelFamily(modelId) {
|
|
344
|
+
const value = normalizeClaudeModelId(modelId).toLowerCase();
|
|
345
|
+
if (!value)
|
|
346
|
+
return null;
|
|
347
|
+
if (value === 'fable' || value.startsWith('claude-fable-'))
|
|
348
|
+
return 'fable';
|
|
349
|
+
if (value === 'opus' || value.startsWith('claude-opus-'))
|
|
350
|
+
return 'opus';
|
|
351
|
+
if (value === 'sonnet' || value.startsWith('claude-sonnet-'))
|
|
352
|
+
return 'sonnet';
|
|
353
|
+
if (value === 'haiku' || value.startsWith('claude-haiku-'))
|
|
354
|
+
return 'haiku';
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
function isClaudeFamilyAlias(modelId) {
|
|
358
|
+
const v = modelId.trim().toLowerCase();
|
|
359
|
+
return v === 'fable' || v === 'opus' || v === 'sonnet' || v === 'haiku';
|
|
360
|
+
}
|
|
361
|
+
export function modelMatchesSelection(agent, selection, currentModel) {
|
|
362
|
+
if (selection === currentModel)
|
|
363
|
+
return true;
|
|
364
|
+
if (agent !== 'claude')
|
|
365
|
+
return false;
|
|
366
|
+
if (!isClaudeFamilyAlias(selection) && !isClaudeFamilyAlias(currentModel))
|
|
367
|
+
return false;
|
|
368
|
+
const a = claudeModelFamily(selection);
|
|
369
|
+
const b = claudeModelFamily(currentModel);
|
|
370
|
+
return !!a && a === b;
|
|
371
|
+
}
|
|
372
|
+
const EFFORT_LEVELS = {
|
|
373
|
+
claude: [
|
|
374
|
+
{ id: 'low', label: 'Low' },
|
|
375
|
+
{ id: 'medium', label: 'Medium' },
|
|
376
|
+
{ id: 'high', label: 'High' },
|
|
377
|
+
{ id: 'xhigh', label: 'Very High' },
|
|
378
|
+
{ id: 'max', label: 'Max' },
|
|
379
|
+
// Synthetic top rung: "max depth + multi-agent Workflow orchestration", the
|
|
380
|
+
// same bundle as Claude's `ultracode` mode. Not a real --effort value — the
|
|
381
|
+
// bot decomposes it into (effort=max, workflow=on) at apply time.
|
|
382
|
+
{ id: 'ultra', label: 'Ultra' },
|
|
383
|
+
],
|
|
384
|
+
codex: [
|
|
385
|
+
{ id: 'low', label: 'Low' },
|
|
386
|
+
{ id: 'medium', label: 'Medium' },
|
|
387
|
+
{ id: 'high', label: 'High' },
|
|
388
|
+
{ id: 'xhigh', label: 'Very High' },
|
|
389
|
+
],
|
|
390
|
+
hermes: [
|
|
391
|
+
{ id: 'minimal', label: 'Minimal' },
|
|
392
|
+
{ id: 'low', label: 'Low' },
|
|
393
|
+
{ id: 'medium', label: 'Medium' },
|
|
394
|
+
{ id: 'high', label: 'High' },
|
|
395
|
+
{ id: 'xhigh', label: 'Very High' },
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
function buildEffortData(bot, agent) {
|
|
399
|
+
// Display value folds workflow into the synthetic `ultra` rung — see
|
|
400
|
+
// Bot.effortSelectionForAgent.
|
|
401
|
+
const currentEffort = bot.effortSelectionForAgent(agent);
|
|
402
|
+
if (!currentEffort)
|
|
403
|
+
return null;
|
|
404
|
+
const levels = EFFORT_LEVELS[agent];
|
|
405
|
+
if (!levels)
|
|
406
|
+
return null;
|
|
407
|
+
return {
|
|
408
|
+
current: currentEffort,
|
|
409
|
+
levels: levels.map(l => ({ ...l, isCurrent: l.id === currentEffort })),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
export async function getModelsListData(bot, chatId) {
|
|
413
|
+
const cs = bot.chat(chatId);
|
|
414
|
+
const currentModel = bot.modelForAgent(cs.agent);
|
|
415
|
+
const activeProfileId = bot.activeProfileIdForAgent(cs.agent);
|
|
416
|
+
const res = await bot.fetchModels(cs.agent, bot.chatWorkdir(chatId));
|
|
417
|
+
// Match priority:
|
|
418
|
+
// - if a Profile is bound, only the row carrying that profileId is current
|
|
419
|
+
// (two Profiles can share a modelId, so id-equality alone is ambiguous);
|
|
420
|
+
// - otherwise only `'native'` rows are eligible to match against the
|
|
421
|
+
// user-config model id.
|
|
422
|
+
return {
|
|
423
|
+
agent: cs.agent,
|
|
424
|
+
currentModel,
|
|
425
|
+
sources: res.sources,
|
|
426
|
+
note: res.note ?? null,
|
|
427
|
+
models: res.models.map(m => {
|
|
428
|
+
const group = m.group ?? 'native';
|
|
429
|
+
const isProfileRow = group !== 'native';
|
|
430
|
+
const isCurrent = activeProfileId
|
|
431
|
+
? !!m.profileId && m.profileId === activeProfileId
|
|
432
|
+
: !isProfileRow && modelMatchesSelection(cs.agent, m.id, currentModel);
|
|
433
|
+
return {
|
|
434
|
+
id: m.id,
|
|
435
|
+
alias: m.alias ?? null,
|
|
436
|
+
isCurrent,
|
|
437
|
+
group,
|
|
438
|
+
profileId: m.profileId ?? null,
|
|
439
|
+
providerName: m.providerName ?? null,
|
|
440
|
+
};
|
|
441
|
+
}),
|
|
442
|
+
effort: buildEffortData(bot, cs.agent),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
export async function getStatusDataAsync(bot, chatId) {
|
|
446
|
+
const d = bot.getStatusData(chatId);
|
|
447
|
+
const driver = getDriver(d.agent);
|
|
448
|
+
const usage = driver.getUsageLive
|
|
449
|
+
? await driver.getUsageLive({ agent: d.agent, model: d.model }).catch(() => d.usage)
|
|
450
|
+
: d.usage;
|
|
451
|
+
return {
|
|
452
|
+
...d,
|
|
453
|
+
running: d.running ? { prompt: d.running.prompt, startedAt: d.running.startedAt } : null,
|
|
454
|
+
usage,
|
|
455
|
+
git: readGitStatus(d.workdir),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
export function getHostDataSync(bot) {
|
|
459
|
+
return bot.getHostData();
|
|
460
|
+
}
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Skill routing
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
export { SKILL_CMD_PREFIX, indexSkillsByCommand };
|
|
465
|
+
function relSkillPath(workdir, filePath) {
|
|
466
|
+
const relative = path.relative(workdir, filePath).replace(/\\/g, '/');
|
|
467
|
+
return relative && !relative.startsWith('..') ? relative : filePath;
|
|
468
|
+
}
|
|
469
|
+
export function resolveSkillPrompt(bot, chatId, cmd, args) {
|
|
470
|
+
const wd = bot.chatWorkdir(chatId);
|
|
471
|
+
const skills = bot.fetchSkills(wd).skills;
|
|
472
|
+
const skill = indexSkillsByCommand(skills).get(cmd);
|
|
473
|
+
if (!skill)
|
|
474
|
+
return null;
|
|
475
|
+
const extra = args.trim();
|
|
476
|
+
const suffix = extra ? ` Additional context: ${extra}` : '';
|
|
477
|
+
const workdirHint = `[Project directory: ${wd}]\n\n`;
|
|
478
|
+
let prompt;
|
|
479
|
+
const paths = getProjectSkillPaths(wd, skill.name);
|
|
480
|
+
const skillFile = paths.claudeSkillFile || paths.sharedSkillFile || paths.agentsSkillFile;
|
|
481
|
+
if (skillFile) {
|
|
482
|
+
prompt = `${workdirHint}Read the skill definition at \`${skillFile}\` and execute the instructions defined there.${suffix}`;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const fallbackPath = `${wd}/.pikiloop/skills/${skill.name}/SKILL.md`;
|
|
486
|
+
prompt = `${workdirHint}Read the skill definition at \`${fallbackPath}\` and execute the instructions defined there.${suffix}`;
|
|
487
|
+
}
|
|
488
|
+
return { prompt, skillName: skill.name };
|
|
489
|
+
}
|
|
490
|
+
export function getExtensionSummaryData(bot, chatId) {
|
|
491
|
+
const workdir = bot.chatWorkdir(chatId);
|
|
492
|
+
const mcpExts = listAllMcpExtensions(workdir);
|
|
493
|
+
const skillResult = listAllSkills(workdir);
|
|
494
|
+
return {
|
|
495
|
+
mcpCount: mcpExts.length,
|
|
496
|
+
mcpExtensions: mcpExts.map(e => ({
|
|
497
|
+
name: e.name,
|
|
498
|
+
scope: e.scope,
|
|
499
|
+
enabled: e.config.enabled !== false && !e.config.disabled,
|
|
500
|
+
command: [e.config.command || '', ...(e.config.args || [])].join(' ').trim(),
|
|
501
|
+
})),
|
|
502
|
+
skillCount: skillResult.skills.length,
|
|
503
|
+
skills: skillResult.skills.map((s) => ({
|
|
504
|
+
name: s.name,
|
|
505
|
+
scope: s.scope || 'project',
|
|
506
|
+
label: s.label || s.name,
|
|
507
|
+
})),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Re-export commonly used helpers for convenience
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
export { summarizePromptForStatus, fmtTokens, fmtUptime, fmtBytes, VERSION };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeadlessBot — the Web Dashboard acting as a first-class terminal.
|
|
3
|
+
*
|
|
4
|
+
* pikiloop's terminals (IM channels and the Dashboard) are equal, pluggable
|
|
5
|
+
* entry points. The IM channels each have a Bot subclass that connects a chat
|
|
6
|
+
* transport; the Dashboard needs no transport — it drives the bot directly via
|
|
7
|
+
* `runtime.getBotRef()`. HeadlessBot fills that gap: it satisfies the Bot
|
|
8
|
+
* contract (`run()` / `requestStop()`) so `ChannelSupervisor` can manage its
|
|
9
|
+
* lifecycle exactly like a channel bot and the dashboard can attach to it for
|
|
10
|
+
* live stream snapshots — but it connects to nothing.
|
|
11
|
+
*
|
|
12
|
+
* It exists so the bot is usable with zero IM channels configured: install an
|
|
13
|
+
* agent, open the dashboard, and you have a working terminal. When an IM
|
|
14
|
+
* channel is later added, the supervisor tears this down and the channel's bot
|
|
15
|
+
* takes over the dashboard attachment.
|
|
16
|
+
*/
|
|
17
|
+
import { Bot } from './bot.js';
|
|
18
|
+
export class HeadlessBot extends Bot {
|
|
19
|
+
resolveRun = null;
|
|
20
|
+
/**
|
|
21
|
+
* No transport to connect — just mark the terminal live and block until
|
|
22
|
+
* `requestStop()` so the supervisor's lifecycle (await runPromise on stop)
|
|
23
|
+
* matches the channel bots'.
|
|
24
|
+
*/
|
|
25
|
+
run() {
|
|
26
|
+
this.connected = true;
|
|
27
|
+
this.log('dashboard terminal ready (no IM channel configured)');
|
|
28
|
+
return new Promise(resolve => { this.resolveRun = resolve; });
|
|
29
|
+
}
|
|
30
|
+
requestStop() {
|
|
31
|
+
this.connected = false;
|
|
32
|
+
super.requestStop();
|
|
33
|
+
this.resolveRun?.();
|
|
34
|
+
this.resolveRun = null;
|
|
35
|
+
}
|
|
36
|
+
}
|