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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,795 @@
1
+ /**
2
+ * Hermes Agent driver — speaks ACP (Agent Client Protocol) to `hermes acp`.
3
+ *
4
+ * Pikiloop owns Provider/Profile credentials in its own vault; this driver
5
+ * reads the active Profile via the model layer's injector and applies env
6
+ * vars when spawning the Hermes ACP child process. Hermes' own config and
7
+ * `hermes auth` command are not touched.
8
+ *
9
+ * Reference: https://agentclientprotocol.com — JSON-RPC 2.0 over stdio.
10
+ * Hermes implements ACP via `~/.hermes/hermes-agent/acp_adapter/server.py`.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { existsSync, readFileSync, statSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join, extname } from 'node:path';
16
+ import { resolve as resolvePath } from 'node:path';
17
+ import { registerDriver } from '../driver.js';
18
+ import { AcpClient, toAcpMcpServers } from '../acp-client.js';
19
+ import { agentLog, agentWarn, emptyUsage, normalizeErrorMessage, listPikiloopSessions, findPikiloopSession, buildStreamPreviewMeta, applyTurnWindow, pushRecentActivity, IMAGE_EXTS, mimeForExt, } from '../index.js';
20
+ // Build the ACP `prompt` content array from the user's text + staged
21
+ // attachments. Images become ImageContentBlocks (base64 + mimeType — the
22
+ // shape Hermes' acp_adapter accepts and converts to OpenAI multimodal
23
+ // content). Non-image attachments are referenced by path so the agent can
24
+ // open them with its own filesystem tools.
25
+ function buildHermesPromptBlocks(prompt, attachments) {
26
+ const blocks = [];
27
+ for (const filePath of attachments) {
28
+ const ext = extname(filePath).toLowerCase();
29
+ if (IMAGE_EXTS.has(ext)) {
30
+ try {
31
+ const data = readFileSync(filePath);
32
+ blocks.push({
33
+ type: 'image',
34
+ data: data.toString('base64'),
35
+ mimeType: mimeForExt(ext),
36
+ });
37
+ continue;
38
+ }
39
+ catch (e) {
40
+ agentWarn(`[hermes] failed to read image ${filePath}: ${e?.message || e}`);
41
+ }
42
+ }
43
+ blocks.push({ type: 'text', text: `[Attached file: ${filePath}]` });
44
+ }
45
+ blocks.push({ type: 'text', text: prompt });
46
+ return blocks;
47
+ }
48
+ function makeStreamState() {
49
+ return {
50
+ text: '', thinking: '', activity: '',
51
+ recentActivity: [],
52
+ toolsById: new Map(),
53
+ inputTokens: null, outputTokens: null, cachedInputTokens: null,
54
+ contextWindow: null, contextUsedTokens: null,
55
+ };
56
+ }
57
+ function applySessionUpdate(state, update) {
58
+ if (!update)
59
+ return false;
60
+ switch (update.sessionUpdate) {
61
+ case 'agent_message_chunk': {
62
+ const t = update.content?.text;
63
+ if (typeof t === 'string') {
64
+ state.text += t;
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ case 'agent_thought_chunk': {
70
+ const t = update.content?.text;
71
+ if (typeof t === 'string') {
72
+ state.thinking += t;
73
+ return true;
74
+ }
75
+ return false;
76
+ }
77
+ case 'tool_call': {
78
+ // ACP tool_call kicks off a new invocation. Accumulate it (mirroring
79
+ // Claude/Codex) so the user sees the full chain in the live footer,
80
+ // not just the title of whatever tool is currently mid-flight.
81
+ const id = typeof update.toolCallId === 'string' ? update.toolCallId : '';
82
+ const title = (typeof update.title === 'string' && update.title.trim()) || 'tool';
83
+ if (id)
84
+ state.toolsById.set(id, { title });
85
+ pushRecentActivity(state.recentActivity, title);
86
+ state.activity = state.recentActivity.join('\n');
87
+ return true;
88
+ }
89
+ case 'tool_call_update': {
90
+ const id = typeof update.toolCallId === 'string' ? update.toolCallId : '';
91
+ const known = id ? state.toolsById.get(id) : null;
92
+ const title = (typeof update.title === 'string' && update.title.trim()) || known?.title || 'tool';
93
+ if (id && typeof update.title === 'string' && update.title.trim()) {
94
+ state.toolsById.set(id, { title });
95
+ }
96
+ if (update.status === 'completed') {
97
+ pushRecentActivity(state.recentActivity, `${title} done`);
98
+ state.activity = state.recentActivity.join('\n');
99
+ }
100
+ else if (update.status === 'failed') {
101
+ pushRecentActivity(state.recentActivity, `${title} failed`);
102
+ state.activity = state.recentActivity.join('\n');
103
+ }
104
+ return true;
105
+ }
106
+ case 'usage_update': {
107
+ // ACP semantics: `size` = model context window, `used` = current
108
+ // context pressure. We previously mis-mapped `size` to
109
+ // contextUsedTokens, which made the chip show the full window
110
+ // (e.g. "1.0M tok" for a 1M-window model) regardless of actual use.
111
+ if (typeof update.size === 'number')
112
+ state.contextWindow = update.size;
113
+ if (typeof update.used === 'number')
114
+ state.contextUsedTokens = update.used;
115
+ return true;
116
+ }
117
+ default:
118
+ return false;
119
+ }
120
+ }
121
+ function makeStreamResult(start, partial = {}) {
122
+ return {
123
+ ok: false, message: '', thinking: null, sessionId: null, workspacePath: null,
124
+ model: null, thinkingEffort: '', elapsedS: (Date.now() - start) / 1000,
125
+ inputTokens: null, outputTokens: null, cachedInputTokens: null,
126
+ cacheCreationInputTokens: null, contextWindow: null, contextUsedTokens: null,
127
+ contextPercent: null, codexCumulative: null, error: null, stopReason: null,
128
+ incomplete: true, activity: null, plan: null,
129
+ ...partial,
130
+ };
131
+ }
132
+ // Empty / refusal heuristic — used to surface a hint when the model's reply
133
+ // is the canonical OpenAI safety refusal with no content. We don't paper
134
+ // over it (the user should see exactly what the model said), but a short
135
+ // pikiloop note tells them this came from the model itself, not a bug.
136
+ const REFUSAL_REGEX = /^(?:i'?m sorry|sorry),?[\s\w,'`]*?(?:can(?:not|'t)|unable to)\s+(?:assist|help)[\s\S]{0,40}$/i;
137
+ // ---------------------------------------------------------------------------
138
+ // ACP-driven streaming
139
+ // ---------------------------------------------------------------------------
140
+ async function doHermesStream(opts) {
141
+ const start = Date.now();
142
+ // BYOK env is populated on opts by stream.ts via resolveAgentInjection('hermes');
143
+ // we just merge it into the spawn env. The bound model is delivered via the
144
+ // ACP `session/set_model` request below — `hermes acp` does NOT accept any
145
+ // CLI flags besides `--accept-hooks`, so we MUST NOT append byokArgvAppend
146
+ // here (doing so would crash the spawn with `unrecognized arguments`).
147
+ const baseEnv = { ...process.env, ...(opts.extraEnv || {}) };
148
+ if (!opts.hermesModel) {
149
+ agentLog(`[hermes] no active profile bound — running with hermes' native config default`);
150
+ }
151
+ const client = new AcpClient({
152
+ command: 'hermes',
153
+ args: ['acp'],
154
+ env: baseEnv,
155
+ cwd: opts.workdir,
156
+ });
157
+ let sessionId = opts.sessionId || null;
158
+ let stopReason = null;
159
+ // Per-turn streaming state. We reset it just before sending session/prompt
160
+ // so any session/update events from the prior session/load replay don't
161
+ // leak into the user-visible reply.
162
+ const state = makeStreamState();
163
+ // While true, sessionUpdate events flow into `state`. When false, they are
164
+ // counted but discarded — used during the session/load replay window so
165
+ // history-replay chunks don't pollute the new turn's reply.
166
+ let consumeUpdates = true;
167
+ // Agent-initiated requests we must respond to (we deny filesystem ops we
168
+ // don't support; pikiloop has its own sandbox/permission model elsewhere).
169
+ client.on('request', ({ id, method }) => {
170
+ if (method === 'session/request_permission') {
171
+ client.respond(id, { outcome: { outcome: 'cancelled' } });
172
+ return;
173
+ }
174
+ if (method === 'fs/read_text_file' || method === 'fs/write_text_file') {
175
+ client.respondError(id, -32601, 'fs methods not supported by pikiloop client');
176
+ return;
177
+ }
178
+ client.respondError(id, -32601, `Method not implemented: ${method}`);
179
+ });
180
+ const buildMeta = () => buildStreamPreviewMeta({
181
+ inputTokens: state.inputTokens,
182
+ outputTokens: state.outputTokens,
183
+ cachedInputTokens: state.cachedInputTokens,
184
+ cacheCreationInputTokens: null,
185
+ contextWindow: state.contextWindow,
186
+ contextUsedTokens: state.contextUsedTokens,
187
+ });
188
+ const onUpdate = (params) => {
189
+ if (!consumeUpdates)
190
+ return;
191
+ if (applySessionUpdate(state, params?.update)) {
192
+ try {
193
+ opts.onText(state.text, state.thinking, state.activity, buildMeta(), null);
194
+ }
195
+ catch { }
196
+ }
197
+ };
198
+ client.on('sessionUpdate', onUpdate);
199
+ // Abort handling
200
+ const onAbort = () => {
201
+ stopReason = 'interrupted';
202
+ if (sessionId)
203
+ client.notify('session/cancel', { sessionId });
204
+ };
205
+ if (opts.abortSignal?.aborted)
206
+ onAbort();
207
+ opts.abortSignal?.addEventListener('abort', onAbort, { once: true });
208
+ try {
209
+ client.start();
210
+ await client.request('initialize', {
211
+ protocolVersion: 1,
212
+ clientCapabilities: {
213
+ fs: { readTextFile: false, writeTextFile: false },
214
+ },
215
+ });
216
+ if (!sessionId) {
217
+ const newSession = await client.request('session/new', {
218
+ cwd: opts.workdir,
219
+ mcpServers: toAcpMcpServers(opts.mcpServers),
220
+ });
221
+ sessionId = newSession?.sessionId || newSession?.session_id || null;
222
+ if (sessionId)
223
+ opts.onSessionId?.(sessionId);
224
+ }
225
+ else {
226
+ // Resumed session in a fresh `hermes acp` process. We MUST call
227
+ // `session/load`, not just rely on the implicit DB restore that
228
+ // `session/prompt` does internally:
229
+ // 1. ACP-registered MCP servers are NOT persisted on disk — Hermes'
230
+ // `_make_agent` (used during DB restore) recreates the agent with
231
+ // *only* its native toolset. Without re-registering, the model
232
+ // sees a different tool surface than turn 1.
233
+ // 2. `session/load` triggers Hermes' history-replay (it streams every
234
+ // prior user/assistant message back as session/update events).
235
+ // Discard them so they don't pollute the new turn's reply.
236
+ consumeUpdates = false;
237
+ try {
238
+ const result = await client.request('session/load', {
239
+ sessionId,
240
+ cwd: opts.workdir,
241
+ mcpServers: toAcpMcpServers(opts.mcpServers),
242
+ }, 30_000);
243
+ if (result === null) {
244
+ agentWarn(`[hermes] session/load returned null for ${sessionId} — session not found in Hermes DB; continuing with a fresh prompt against the existing id`);
245
+ }
246
+ else {
247
+ // Replay events arrive on Hermes' event loop AFTER session/load
248
+ // resolves. Wait until the stream goes quiet for ~150ms (or 3s
249
+ // hard cap) — that's a more robust signal than a fixed sleep.
250
+ const drained = await client.waitForQuiet(150, 3_000);
251
+ if (drained > 0)
252
+ agentLog(`[hermes] drained ${drained} replay event(s) after session/load`);
253
+ }
254
+ }
255
+ catch (e) {
256
+ agentWarn(`[hermes] session/load failed (${sessionId}): ${e?.message || e} — proceeding without re-registration`);
257
+ }
258
+ }
259
+ if (!sessionId)
260
+ throw new Error('Hermes did not return a session id');
261
+ // If a Profile is bound, override Hermes' config-default model via ACP.
262
+ // `set_model` is best-effort: a Hermes that doesn't recognise the model id
263
+ // (or doesn't have credentials for the requested provider) responds with
264
+ // an error, but we keep going — the user will see that error in the first
265
+ // prompt response and can re-pick a working profile, which is far better
266
+ // than crashing the whole spawn.
267
+ if (opts.hermesModel) {
268
+ try {
269
+ await client.request('session/set_model', {
270
+ sessionId,
271
+ modelId: opts.hermesModel,
272
+ }, 15_000);
273
+ agentLog(`[hermes] bound model: ${opts.hermesModel}`);
274
+ }
275
+ catch (e) {
276
+ agentWarn(`[hermes] session/set_model failed (${opts.hermesModel}): ${e?.message || e} — falling back to Hermes' config default`);
277
+ }
278
+ }
279
+ // Best-effort: forward the chosen reasoning effort via ACP `session/set_mode`.
280
+ // Current `hermes acp` accepts the request but only persists the value on
281
+ // the session record (it doesn't influence generation). When Hermes adds a
282
+ // real effort knob to the ACP surface, this call will start taking effect
283
+ // without any pikiloop change.
284
+ if (opts.thinkingEffort) {
285
+ await client.tryRequest('session/set_mode', {
286
+ sessionId,
287
+ modeId: opts.thinkingEffort,
288
+ });
289
+ }
290
+ // Reset the buffer one last time before we start consuming updates for
291
+ // the prompt. Anything that arrived during/after the drain (latency
292
+ // stragglers from the replay) gets discarded too.
293
+ state.text = '';
294
+ state.thinking = '';
295
+ state.activity = '';
296
+ state.recentActivity = [];
297
+ state.toolsById.clear();
298
+ consumeUpdates = true;
299
+ const promptResponse = await client.request('session/prompt', {
300
+ sessionId,
301
+ prompt: buildHermesPromptBlocks(opts.prompt, opts.attachments || []),
302
+ }, Math.max(opts.timeout * 1000, 30_000));
303
+ stopReason = promptResponse?.stopReason || 'end_turn';
304
+ // `PromptResponse.usage` (ACP) carries real per-turn token counts from the
305
+ // provider. `usage_update` notifications, by contrast, describe the
306
+ // session's *context window pressure* (size/used), not this turn's I/O.
307
+ const usage = promptResponse?.usage;
308
+ if (usage && typeof usage === 'object') {
309
+ const input = usage.inputTokens ?? usage.input_tokens;
310
+ const output = usage.outputTokens ?? usage.output_tokens;
311
+ const cached = usage.cachedReadTokens ?? usage.cached_read_tokens;
312
+ if (typeof input === 'number')
313
+ state.inputTokens = input;
314
+ if (typeof output === 'number')
315
+ state.outputTokens = output;
316
+ if (typeof cached === 'number')
317
+ state.cachedInputTokens = cached;
318
+ }
319
+ const messageText = state.text.trim();
320
+ const isRefusalOnly = !!messageText && messageText.length < 120 && REFUSAL_REGEX.test(messageText);
321
+ return makeStreamResult(start, {
322
+ ok: !isRefusalOnly,
323
+ message: messageText || '(no textual response)',
324
+ thinking: state.thinking.trim() || null,
325
+ sessionId,
326
+ model: opts.model,
327
+ thinkingEffort: opts.thinkingEffort,
328
+ inputTokens: state.inputTokens,
329
+ outputTokens: state.outputTokens,
330
+ cachedInputTokens: state.cachedInputTokens,
331
+ contextWindow: state.contextWindow,
332
+ contextUsedTokens: state.contextUsedTokens,
333
+ stopReason,
334
+ incomplete: stopReason !== 'end_turn',
335
+ activity: null,
336
+ // When the model itself refuses, mark as incomplete and add a hint so
337
+ // the user can tell it's the model's choice (not a pikiloop error).
338
+ error: isRefusalOnly
339
+ ? `Model returned a safety refusal. Try a different model on the agent card (e.g. claude-haiku-4.5 via OpenRouter), or check ~/.hermes/config.yaml.`
340
+ : null,
341
+ elapsedS: (Date.now() - start) / 1000,
342
+ });
343
+ }
344
+ catch (e) {
345
+ const message = normalizeErrorMessage(e) || 'Hermes ACP stream failed.';
346
+ agentWarn(`[hermes] stream error: ${message}`);
347
+ return makeStreamResult(start, {
348
+ ok: false,
349
+ message: state.text.trim() || message,
350
+ thinking: state.thinking.trim() || null,
351
+ sessionId,
352
+ model: opts.model,
353
+ thinkingEffort: opts.thinkingEffort,
354
+ error: message,
355
+ stopReason,
356
+ incomplete: true,
357
+ elapsedS: (Date.now() - start) / 1000,
358
+ });
359
+ }
360
+ finally {
361
+ opts.abortSignal?.removeEventListener('abort', onAbort);
362
+ await client.close().catch(() => { });
363
+ }
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Sessions / models / usage — minimal surface
367
+ // ---------------------------------------------------------------------------
368
+ async function getHermesSessions(workdir, limit) {
369
+ // Hermes' own session list lives in a SQLite DB inside ~/.hermes — useful
370
+ // for the `hermes sessions` CLI but irrelevant to pikiloop, which always
371
+ // creates its own ACP session per turn and records it under .pikiloop.
372
+ const resolvedWorkdir = resolvePath(workdir);
373
+ const records = listPikiloopSessions(resolvedWorkdir, 'hermes');
374
+ const sessions = records.map(record => ({
375
+ sessionId: record.sessionId,
376
+ agent: 'hermes',
377
+ workdir: record.workdir,
378
+ workspacePath: record.workspacePath,
379
+ threadId: record.threadId,
380
+ model: record.model,
381
+ createdAt: record.createdAt,
382
+ title: record.title,
383
+ running: record.runState === 'running',
384
+ runState: record.runState,
385
+ runDetail: record.runDetail,
386
+ runUpdatedAt: record.runUpdatedAt,
387
+ runPid: record.runPid,
388
+ classification: record.classification,
389
+ userStatus: record.userStatus,
390
+ userNote: record.userNote,
391
+ lastQuestion: record.lastQuestion,
392
+ lastAnswer: record.lastAnswer,
393
+ lastMessageText: record.lastMessageText,
394
+ migratedFrom: record.migratedFrom,
395
+ migratedTo: record.migratedTo,
396
+ linkedSessions: record.linkedSessions,
397
+ numTurns: record.numTurns ?? null,
398
+ }));
399
+ sessions.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
400
+ const sliced = typeof limit === 'number' ? sessions.slice(0, limit) : sessions;
401
+ agentLog(`[sessions:hermes] workdir=${resolvedWorkdir} pikiloop=${records.length} returned=${sliced.length}`);
402
+ return { ok: true, sessions: sliced, error: null };
403
+ }
404
+ async function getHermesSessionTail(_opts) {
405
+ return { ok: true, messages: [], error: null };
406
+ }
407
+ // Hermes mirrors every ACP/CLI session to ~/.hermes/sessions/session_<id>.json
408
+ // (in addition to the SQLite store at ~/.hermes/state.db). The JSON file is
409
+ // authoritative for full conversation replay: it carries the OpenAI-style
410
+ // `messages[]` stream, including assistant `reasoning_content` and any
411
+ // `tool_calls[] / role:"tool"` pairs. Reading it is ~20ms vs. a 4+ s ACP
412
+ // `session/load` replay round-trip, so we use it directly.
413
+ function hermesSessionJsonPath(sessionId) {
414
+ return join(homedir(), '.hermes', 'sessions', `session_${sessionId}.json`);
415
+ }
416
+ function extractHermesContentText(content) {
417
+ if (typeof content === 'string')
418
+ return content;
419
+ // OpenAI multimodal: array of { type, text | image_url, ... }.
420
+ if (Array.isArray(content)) {
421
+ const parts = [];
422
+ for (const part of content) {
423
+ if (!part || typeof part !== 'object')
424
+ continue;
425
+ const p = part;
426
+ if (p.type === 'text' && typeof p.text === 'string')
427
+ parts.push(p.text);
428
+ else if (p.type === 'image_url' || p.type === 'input_image')
429
+ parts.push('[image]');
430
+ }
431
+ return parts.join('\n').trim();
432
+ }
433
+ return '';
434
+ }
435
+ function formatHermesArgs(raw) {
436
+ if (raw == null)
437
+ return '';
438
+ if (typeof raw === 'string') {
439
+ const trimmed = raw.trim();
440
+ if (!trimmed)
441
+ return '';
442
+ // tool_calls.function.arguments is a JSON string — pretty-print when valid.
443
+ try {
444
+ const parsed = JSON.parse(trimmed);
445
+ return JSON.stringify(parsed, null, 2);
446
+ }
447
+ catch {
448
+ return trimmed;
449
+ }
450
+ }
451
+ try {
452
+ return JSON.stringify(raw, null, 2);
453
+ }
454
+ catch {
455
+ return String(raw);
456
+ }
457
+ }
458
+ function buildHermesAssistantText(blocks) {
459
+ return blocks
460
+ .filter(b => b.type === 'text' && b.content.trim())
461
+ .map(b => b.content.trim())
462
+ .join('\n\n')
463
+ .trim();
464
+ }
465
+ function getHermesSessionMessagesFromJson(opts) {
466
+ const path = hermesSessionJsonPath(opts.sessionId);
467
+ if (!existsSync(path))
468
+ return null;
469
+ let parsed;
470
+ try {
471
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
472
+ }
473
+ catch (e) {
474
+ agentWarn(`[hermes] failed to parse session JSON ${path}: ${e?.message || e}`);
475
+ return null;
476
+ }
477
+ const rawMessages = Array.isArray(parsed?.messages) ? parsed.messages : [];
478
+ if (!rawMessages.length)
479
+ return { ok: true, messages: [], richMessages: [], totalTurns: 0, error: null };
480
+ const allMsgs = [];
481
+ const richMsgs = [];
482
+ let pending = null;
483
+ const ensureAssistant = () => {
484
+ if (!pending)
485
+ pending = { blocks: [], toolNamesByCallId: new Map() };
486
+ return pending;
487
+ };
488
+ const flushAssistant = () => {
489
+ if (!pending)
490
+ return;
491
+ const blocks = pending.blocks.filter(b => b.type === 'tool_use' || b.type === 'tool_result' || !!b.content.trim());
492
+ pending = null;
493
+ if (!blocks.length)
494
+ return;
495
+ const text = buildHermesAssistantText(blocks);
496
+ allMsgs.push({ role: 'assistant', text });
497
+ richMsgs.push({ role: 'assistant', text, blocks });
498
+ };
499
+ for (const msg of rawMessages) {
500
+ if (!msg || typeof msg !== 'object')
501
+ continue;
502
+ const role = msg.role;
503
+ if (role === 'system')
504
+ continue;
505
+ if (role === 'user') {
506
+ flushAssistant();
507
+ const text = extractHermesContentText(msg.content).trim();
508
+ if (!text)
509
+ continue;
510
+ allMsgs.push({ role: 'user', text });
511
+ richMsgs.push({ role: 'user', text, blocks: [{ type: 'text', content: text }] });
512
+ continue;
513
+ }
514
+ if (role === 'assistant') {
515
+ const a = ensureAssistant();
516
+ const reasoning = typeof msg.reasoning_content === 'string' && msg.reasoning_content.trim()
517
+ ? msg.reasoning_content
518
+ : (typeof msg.reasoning === 'string' ? msg.reasoning : '');
519
+ if (reasoning && reasoning.trim()) {
520
+ a.blocks.push({ type: 'thinking', content: reasoning });
521
+ }
522
+ const text = extractHermesContentText(msg.content);
523
+ if (text && text.trim()) {
524
+ a.blocks.push({ type: 'text', content: text, phase: 'final_answer' });
525
+ }
526
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
527
+ for (const tc of toolCalls) {
528
+ if (!tc || typeof tc !== 'object')
529
+ continue;
530
+ const fn = tc.function || {};
531
+ const name = typeof fn.name === 'string' ? fn.name.trim() : '';
532
+ const callId = typeof tc.id === 'string'
533
+ ? tc.id
534
+ : (typeof tc.call_id === 'string' ? tc.call_id : '');
535
+ if (!name)
536
+ continue;
537
+ if (callId)
538
+ a.toolNamesByCallId.set(callId, name);
539
+ a.blocks.push({
540
+ type: 'tool_use',
541
+ content: formatHermesArgs(fn.arguments),
542
+ toolName: name,
543
+ toolId: callId || undefined,
544
+ });
545
+ }
546
+ continue;
547
+ }
548
+ if (role === 'tool') {
549
+ const a = ensureAssistant();
550
+ const callId = typeof msg.tool_call_id === 'string' ? msg.tool_call_id : '';
551
+ const toolName = (callId && a.toolNamesByCallId.get(callId))
552
+ || (typeof msg.tool_name === 'string' && msg.tool_name) || '';
553
+ const output = formatHermesArgs(msg.content);
554
+ a.blocks.push({
555
+ type: 'tool_result',
556
+ content: output,
557
+ toolName: toolName || undefined,
558
+ toolId: callId || undefined,
559
+ });
560
+ continue;
561
+ }
562
+ // Unknown role — ignore silently.
563
+ }
564
+ flushAssistant();
565
+ return applyTurnWindow(allMsgs, opts, opts.rich !== false ? richMsgs : undefined);
566
+ }
567
+ function getHermesSessionMessagesFromRecord(opts) {
568
+ // Fallback for sessions that pre-date the JSON store (or that Hermes wrote
569
+ // somewhere we can't see). Synthesizes the most recent turn from the
570
+ // pikiloop session record so the dashboard still has *something* after the
571
+ // live snapshot expires.
572
+ const record = findPikiloopSession(opts.workdir, 'hermes', opts.sessionId);
573
+ if (!record || (!record.lastQuestion && !record.lastAnswer && !record.lastThinking)) {
574
+ return { ok: true, messages: [], totalTurns: 0, error: null };
575
+ }
576
+ const messages = [];
577
+ const richMessages = [];
578
+ if (record.lastQuestion) {
579
+ messages.push({ role: 'user', text: record.lastQuestion });
580
+ richMessages.push({
581
+ role: 'user',
582
+ text: record.lastQuestion,
583
+ blocks: [{ type: 'text', content: record.lastQuestion }],
584
+ });
585
+ }
586
+ if (record.lastAnswer || record.lastThinking) {
587
+ const answerText = record.lastAnswer || '';
588
+ messages.push({ role: 'assistant', text: answerText });
589
+ const blocks = [];
590
+ if (record.lastThinking)
591
+ blocks.push({ type: 'thinking', content: record.lastThinking });
592
+ if (answerText)
593
+ blocks.push({ type: 'text', content: answerText });
594
+ richMessages.push({ role: 'assistant', text: answerText, blocks });
595
+ }
596
+ const totalTurns = record.numTurns ?? (richMessages.length ? 1 : 0);
597
+ return {
598
+ ok: true,
599
+ messages,
600
+ richMessages,
601
+ totalTurns,
602
+ window: {
603
+ offset: 0,
604
+ limit: 1,
605
+ returnedTurns: richMessages.length ? 1 : 0,
606
+ totalTurns,
607
+ hasOlder: totalTurns > 1,
608
+ hasNewer: false,
609
+ startTurn: Math.max(0, totalTurns - 1),
610
+ endTurn: totalTurns,
611
+ },
612
+ error: null,
613
+ };
614
+ }
615
+ async function getHermesSessionMessages(opts) {
616
+ const fromJson = getHermesSessionMessagesFromJson(opts);
617
+ if (fromJson && fromJson.totalTurns > 0)
618
+ return fromJson;
619
+ return getHermesSessionMessagesFromRecord(opts);
620
+ }
621
+ async function listHermesModels(_opts) {
622
+ // When Hermes has its own working config (typical case — user ran `hermes auth`
623
+ // and `hermes config`), surface the configured default model so the dashboard
624
+ // and IM /models reflect what Hermes will actually use without forcing the
625
+ // user to re-configure inside pikiloop.
626
+ const native = readHermesNativeConfig();
627
+ const models = native?.model
628
+ ? [{ id: native.model, alias: `${native.provider} (Hermes config)` }]
629
+ : [];
630
+ return {
631
+ agent: 'hermes',
632
+ models,
633
+ sources: native ? [`~/.hermes/config.yaml · ${native.provider}`] : ['~/.hermes/config.yaml (not configured)'],
634
+ note: native
635
+ ? `Reading Hermes' own config. Bind a pikiloop Provider on the agent card to override.`
636
+ : `Run \`hermes config\` to set a default model, or bind a pikiloop Provider on the agent card.`,
637
+ };
638
+ }
639
+ // ---------------------------------------------------------------------------
640
+ // Native config reader
641
+ //
642
+ // Pikiloop never writes to ~/.hermes/config.yaml — that is Hermes' own state.
643
+ // We just *read* a few fields so the dashboard can show what Hermes will run
644
+ // with when no BYOK Profile is bound, and so the UI doesn't pretend that an
645
+ // already-configured Hermes "needs" to be re-configured inside pikiloop.
646
+ //
647
+ // Limited surface: only the top-level `model.*` and `agent.reasoning_effort`
648
+ // keys we care about. Implemented with simple string matching (no YAML
649
+ // dependency) since the schema is stable and shallow.
650
+ // ---------------------------------------------------------------------------
651
+ let cachedNativeConfig = null;
652
+ function readHermesNativeConfig() {
653
+ const path = join(homedir(), '.hermes', 'config.yaml');
654
+ if (!existsSync(path))
655
+ return null;
656
+ // Cheap mtime check — re-read only when the file has changed.
657
+ let mtimeMs;
658
+ try {
659
+ mtimeMs = statSync(path).mtimeMs;
660
+ }
661
+ catch {
662
+ mtimeMs = 0;
663
+ }
664
+ if (cachedNativeConfig && cachedNativeConfig.path === path && cachedNativeConfig.mtimeMs === mtimeMs) {
665
+ return cachedNativeConfig.value;
666
+ }
667
+ let text;
668
+ try {
669
+ text = readFileSync(path, 'utf8');
670
+ }
671
+ catch {
672
+ return null;
673
+ }
674
+ const blockOf = (name) => {
675
+ // Captures consecutive indented lines under `name:` (a top-level mapping).
676
+ // Stops at the first non-indented line, which marks the next top-level
677
+ // key. Plain (no `m` flag) so `.` matches across lines via [^\n].
678
+ const re = new RegExp(`(?:^|\\n)${name}:[ \\t]*\\n((?:[ \\t]+[^\\n]*\\n?)+)`);
679
+ return text.match(re)?.[1] || '';
680
+ };
681
+ const valueOf = (block, key) => {
682
+ // The key must be at any indentation level deeper than the parent.
683
+ const m = block.match(new RegExp(`(?:^|\\n)[ \\t]+${key}:[ \\t]*([^\\n]*)`));
684
+ if (!m)
685
+ return null;
686
+ return m[1].trim().replace(/^["']|["']$/g, '') || null;
687
+ };
688
+ const modelBlock = blockOf('model');
689
+ const agentBlock = blockOf('agent');
690
+ const model = valueOf(modelBlock, 'default');
691
+ const provider = valueOf(modelBlock, 'provider');
692
+ const baseURL = valueOf(modelBlock, 'base_url');
693
+ const effort = valueOf(agentBlock, 'reasoning_effort');
694
+ const value = (model && provider)
695
+ ? { model, provider, baseURL: baseURL || null, effort: effort || null, configPath: path, source: 'hermes' }
696
+ : null;
697
+ cachedNativeConfig = { value, mtimeMs, path };
698
+ return value;
699
+ }
700
+ function getHermesNativeConfig() {
701
+ return readHermesNativeConfig();
702
+ }
703
+ function getHermesUsage(_opts) {
704
+ return emptyUsage('hermes', 'Run `hermes insights` for token analytics.');
705
+ }
706
+ async function getHermesUsageLive(_opts) {
707
+ // Spawn `hermes insights --days 30 --source tool` and parse output.
708
+ return new Promise(resolve => {
709
+ let stdout = '';
710
+ let stderr = '';
711
+ try {
712
+ const proc = spawn('hermes', ['insights', '--days', '30', '--source', 'tool'], {
713
+ env: process.env,
714
+ stdio: ['ignore', 'pipe', 'pipe'],
715
+ });
716
+ proc.stdout.on('data', (b) => { stdout += b.toString(); });
717
+ proc.stderr.on('data', (b) => { stderr += b.toString(); });
718
+ const timeout = setTimeout(() => { try {
719
+ proc.kill('SIGTERM');
720
+ }
721
+ catch { } }, 8_000);
722
+ proc.on('close', (code) => {
723
+ clearTimeout(timeout);
724
+ if (code !== 0) {
725
+ resolve(emptyUsage('hermes', `hermes insights exited ${code}: ${stderr.trim().slice(0, 200)}`));
726
+ return;
727
+ }
728
+ const windows = parseHermesInsightsOutput(stdout);
729
+ resolve({
730
+ ok: true,
731
+ agent: 'hermes',
732
+ source: 'hermes-insights',
733
+ capturedAt: new Date().toISOString(),
734
+ status: null,
735
+ windows,
736
+ error: null,
737
+ });
738
+ });
739
+ proc.on('error', err => {
740
+ clearTimeout(timeout);
741
+ resolve(emptyUsage('hermes', `hermes insights error: ${err.message}`));
742
+ });
743
+ }
744
+ catch (e) {
745
+ resolve(emptyUsage('hermes', e?.message || String(e)));
746
+ }
747
+ });
748
+ }
749
+ function parseHermesInsightsOutput(text) {
750
+ // Minimal parser: extract Sessions / Total tokens summary line.
751
+ const out = [];
752
+ const sessionsMatch = text.match(/Sessions:\s+(\d+)/);
753
+ const totalTokensMatch = text.match(/Total tokens:\s+([\d,]+)/);
754
+ if (sessionsMatch || totalTokensMatch) {
755
+ out.push({
756
+ label: 'Last 30d',
757
+ usedPercent: null,
758
+ remainingPercent: null,
759
+ resetAt: null,
760
+ resetAfterSeconds: null,
761
+ status: [
762
+ sessionsMatch ? `${sessionsMatch[1]} sessions` : '',
763
+ totalTokensMatch ? `${totalTokensMatch[1]} tokens` : '',
764
+ ].filter(Boolean).join(' · '),
765
+ });
766
+ }
767
+ return out;
768
+ }
769
+ // ---------------------------------------------------------------------------
770
+ // Driver registration
771
+ // ---------------------------------------------------------------------------
772
+ const HermesDriver = {
773
+ id: 'hermes',
774
+ cmd: 'hermes',
775
+ thinkLabel: 'Reasoning',
776
+ // Hermes locks the model at profile-binding time. The ACP `session/set_model`
777
+ // hook exists but is unreliable across providers in current Hermes builds, so
778
+ // pikiloop treats the model as fixed for the session and hides the picker.
779
+ capabilities: { fork: false, modelSwitch: false, workflow: false },
780
+ // Hermes is BYOK-only — every Profile kind is fair game.
781
+ acceptedProviderKinds: ['anthropic', 'openai', 'openai-compatible', 'google'],
782
+ doStream: doHermesStream,
783
+ getSessions: getHermesSessions,
784
+ getSessionTail: getHermesSessionTail,
785
+ getSessionMessages: getHermesSessionMessages,
786
+ listModels: listHermesModels,
787
+ getUsage: getHermesUsage,
788
+ getUsageLive: getHermesUsageLive,
789
+ getNativeConfig: getHermesNativeConfig,
790
+ shutdown() {
791
+ /* Per-stream AcpClient is closed in doStream finally; nothing process-wide. */
792
+ },
793
+ };
794
+ registerDriver(HermesDriver);
795
+ export { doHermesStream, REFUSAL_REGEX };