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,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 };
|