Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72290f0809 | ||
|
|
b1fe2107d6 | ||
|
|
73969771a5 | ||
|
|
2ee69d0579 | ||
|
|
021ed69141 | ||
|
|
6c752ca3a5 | ||
|
|
acb2954d82 | ||
|
|
8f8cad7ec5 | ||
|
|
d5e2fbf244 | ||
|
|
484f484c25 | ||
|
|
114e265737 | ||
|
|
32a73010bb | ||
|
|
93764b9303 | ||
|
|
c3464ecf45 | ||
|
|
e080365a7a | ||
|
|
5e5308d34d | ||
|
|
08b1c44a53 | ||
|
|
020ef76cf1 | ||
|
|
13650ab7f8 | ||
|
|
4e9be3ee32 | ||
|
|
e7ae145ac4 | ||
|
|
ce99a81123 | ||
|
|
743c55efa3 | ||
|
|
93a2f680fd | ||
|
|
8505e9d669 | ||
|
|
a4f179c509 | ||
|
|
cb29e8a82e | ||
|
|
3c489fda81 | ||
|
|
e8b757845d | ||
|
|
e976faac7a | ||
|
|
1593ca5406 | ||
|
|
9a09ea69fb | ||
|
|
4d6a133a9f | ||
|
|
c7bfc938d5 | ||
|
|
9121834b31 | ||
|
|
56a0f48ba6 | ||
|
|
8878484f85 | ||
|
|
db79e90130 | ||
|
|
51f47f9a97 |
@@ -89,6 +89,9 @@ website/static/api/skills-index.json
|
||||
# every build).
|
||||
website/static/api/skills.json
|
||||
website/static/api/skills-meta.json
|
||||
# automation-blueprints-index.json is a build artifact emitted by
|
||||
# website/scripts/extract-automation-blueprints.py during prebuild.
|
||||
website/static/api/automation-blueprints-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
|
||||
+38
-22
@@ -38,12 +38,13 @@ session (deferred), the same contract as ``/skills install`` vs ``--now``.
|
||||
|
||||
Activation (config ``agent.coding_context``):
|
||||
|
||||
* ``auto`` (default) — posture (brief + snapshot + names-only demotion of
|
||||
non-coding skill categories) on an interactive coding surface sitting in
|
||||
a code workspace (git repo or recognised project root). Prompt-only;
|
||||
toolsets untouched, no skill is ever hidden.
|
||||
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||
surface sitting in a code workspace (git repo or recognised project root).
|
||||
Prompt-only; toolsets and the skill index untouched.
|
||||
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
|
||||
``coding`` set + enabled MCP servers and demotes non-coding skill
|
||||
categories to names-only in the prompt's skill index (no skill is ever
|
||||
hidden). Explicit opt-in for a lean schema.
|
||||
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||
* ``off`` — disable entirely.
|
||||
"""
|
||||
@@ -105,13 +106,19 @@ _GIT_TIMEOUT = 2.5
|
||||
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||
#
|
||||
# GPT/Codex get V4A for ALL edits, single-file included: in codex-rs,
|
||||
# apply_patch (V4A — apply_patch.lark) is the ONLY file editor, no
|
||||
# str_replace-style tool exists, and the shipped model prompts say to use
|
||||
# apply_patch even "for single file edits" — so a replace-mode nudge would
|
||||
# steer those models toward a format their first-party harness never taught
|
||||
# them.
|
||||
_EDIT_FORMAT_GUIDANCE: dict[str, tuple[tuple[str, ...], str]] = {
|
||||
"patch": (
|
||||
("gpt", "codex"),
|
||||
"- Edit format: author new files with `write_file`; for edits to "
|
||||
"existing code prefer `patch` with `mode='patch'` (V4A multi-file diff) "
|
||||
"for structured or multi-file changes — it's the diff format you handle "
|
||||
"most reliably. Use `mode='replace'` for a single small swap.",
|
||||
"existing code use `patch` with `mode='patch'` (V4A diff) — including "
|
||||
"single-file edits. It's the edit format you handle most reliably.",
|
||||
),
|
||||
"replace": (
|
||||
("claude", "sonnet", "opus", "haiku",
|
||||
@@ -214,8 +221,8 @@ class ContextProfile:
|
||||
(extension seam; not yet consumed by the router).
|
||||
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||
``compact_skill_categories`` — skill categories DEMOTED to names-only in
|
||||
the system-prompt skill index while this posture is
|
||||
active. Never hidden: every skill name stays visible
|
||||
the system-prompt skill index under the opt-in ``focus``
|
||||
mode. Never hidden: every skill name stays visible
|
||||
(so memory-anchored recall keeps working) — only the
|
||||
descriptions are dropped to cut index noise. Deny-list
|
||||
semantics so unknown/custom categories keep full
|
||||
@@ -231,7 +238,7 @@ class ContextProfile:
|
||||
|
||||
|
||||
# Skill categories that are clearly not part of a coding workflow. Demoted to
|
||||
# names-only in the prompt's skill index while the coding posture is active
|
||||
# names-only in the prompt's skill index under the opt-in ``focus`` mode only
|
||||
# (deny-list — anything not listed here, incl. custom user categories, keeps
|
||||
# full entries). Coding-adjacent categories (devops, github, mcp,
|
||||
# data-science, diagramming, research, security, …) are intentionally absent.
|
||||
@@ -438,15 +445,22 @@ class RuntimeMode:
|
||||
def compact_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to demote to names-only in the prompt's skill index.
|
||||
|
||||
Demoted — never hidden. An earlier revision fully pruned these
|
||||
categories from the index, which caused silent capability loss in a
|
||||
real workflow: agent-created skills are the model's accumulated
|
||||
project memory (server-ops runbooks, learned pitfalls, …), and models
|
||||
do not reliably reach for ``skills_list`` to rediscover what the
|
||||
index stopped showing them. Names-only keeps every skill loadable on
|
||||
recall while still cutting the description noise from the index.
|
||||
Gated on the opt-in ``focus`` mode, like the toolset collapse: the
|
||||
default posture leaves the skill index untouched. Users who didn't ask
|
||||
for a lean prompt keep full entries for every category — index changes
|
||||
under ``auto`` proved too surprising in practice, even names-only ones
|
||||
(a demoted description is information the model no longer weighs when
|
||||
deciding what to load).
|
||||
|
||||
Demoted — never hidden — even under ``focus``. An earlier revision
|
||||
fully pruned these categories from the index, which caused silent
|
||||
capability loss in a real workflow: agent-created skills are the
|
||||
model's accumulated project memory (server-ops runbooks, learned
|
||||
pitfalls, …), and models do not reliably reach for ``skills_list`` to
|
||||
rediscover what the index stopped showing them. Names-only keeps every
|
||||
skill loadable on recall while still cutting the description noise.
|
||||
"""
|
||||
if not self.is_coding:
|
||||
if not self.is_coding or self.config_mode != "focus":
|
||||
return frozenset()
|
||||
return frozenset(self.profile.compact_skill_categories)
|
||||
|
||||
@@ -534,9 +548,11 @@ def coding_compact_skill_categories(
|
||||
) -> frozenset[str]:
|
||||
"""Skill categories the active posture demotes to names-only in the index.
|
||||
|
||||
Empty outside the coding posture. Demoted — never hidden: every skill
|
||||
name stays in the index and remains loadable via ``skill_view`` /
|
||||
``skills_list``; only descriptions are dropped.
|
||||
Empty outside the coding posture and outside the opt-in ``focus`` mode —
|
||||
the default posture never touches the skill index. Under ``focus``,
|
||||
demoted — never hidden: every skill name stays in the index and remains
|
||||
loadable via ``skill_view`` / ``skills_list``; only descriptions are
|
||||
dropped.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
|
||||
+56
-23
@@ -7,7 +7,7 @@ protecting head and tail context.
|
||||
Improvements over v2:
|
||||
- Structured summary template with Resolved/Pending question tracking
|
||||
- Filter-safe summarizer preamble that treats prior turns as source material
|
||||
- "Remaining Work" replaces "Next Steps" to avoid reading as active instructions
|
||||
- Historical (reference-only) section headings replace "Next Steps"/"Remaining Work" to avoid reading as active instructions
|
||||
- Clear separator when summary merges into tail message
|
||||
- Iterative summary updates (preserves info across multiple compactions)
|
||||
- Token-budget tail protection instead of fixed message count
|
||||
@@ -34,7 +34,50 @@ from agent.redact import redact_sensitive_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HISTORICAL_TASK_HEADING = "## Historical Task Snapshot"
|
||||
HISTORICAL_IN_PROGRESS_HEADING = "## Historical In-Progress State"
|
||||
HISTORICAL_PENDING_ASKS_HEADING = "## Historical Pending User Asks"
|
||||
HISTORICAL_REMAINING_WORK_HEADING = "## Historical Remaining Work"
|
||||
|
||||
|
||||
SUMMARY_PREFIX = (
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Respond ONLY to the latest user message that appears AFTER this "
|
||||
"summary — that message is the single source of truth for what to do "
|
||||
"right now. "
|
||||
"Topic overlap with the summary does NOT mean you should resume its "
|
||||
"task: even on similar topics, the latest user message WINS. Treat ONLY "
|
||||
"the latest message as the active task and discard stale items from "
|
||||
f"'{HISTORICAL_TASK_HEADING}' / '{HISTORICAL_IN_PROGRESS_HEADING}' / "
|
||||
f"'{HISTORICAL_PENDING_ASKS_HEADING}' / "
|
||||
f"'{HISTORICAL_REMAINING_WORK_HEADING}' entirely — do not 'wrap up' or "
|
||||
"'finish' work described there unless the latest message explicitly "
|
||||
"asks for it. "
|
||||
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
|
||||
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
|
||||
"topic) must immediately end any in-flight work described in the "
|
||||
"summary; do not re-surface it in later turns. "
|
||||
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
# Carveout era (#41607/#38364/#42812): "consistent → use as background"
|
||||
# licensed stale-task resumption on topic overlap.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
@@ -57,17 +100,7 @@ SUMMARY_PREFIX = (
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
"described here — avoid repeating it:",
|
||||
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
@@ -1155,7 +1188,7 @@ class ContextCompressor(ContextEngine):
|
||||
)
|
||||
|
||||
reason_text = f" Summary failure reason: {reason}." if reason else ""
|
||||
body = f"""## Active Task
|
||||
body = f"""{HISTORICAL_TASK_HEADING}
|
||||
{active_task}
|
||||
|
||||
## Goal
|
||||
@@ -1172,7 +1205,7 @@ Recovered from a deterministic fallback because the LLM context summarizer was u
|
||||
## Active State
|
||||
Unknown from deterministic fallback. Inspect current repository/session state if needed.
|
||||
|
||||
## In Progress
|
||||
{HISTORICAL_IN_PROGRESS_HEADING}
|
||||
{active_task}
|
||||
|
||||
## Blocked
|
||||
@@ -1184,13 +1217,13 @@ None recoverable from deterministic fallback.
|
||||
## Resolved Questions
|
||||
None recoverable from deterministic fallback.
|
||||
|
||||
## Pending User Asks
|
||||
{HISTORICAL_PENDING_ASKS_HEADING}
|
||||
{active_task}
|
||||
|
||||
## Relevant Files
|
||||
{_bullets(relevant_files, limit=12)}
|
||||
|
||||
## Remaining Work
|
||||
{HISTORICAL_REMAINING_WORK_HEADING}
|
||||
Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims.
|
||||
|
||||
## Last Dropped Turns
|
||||
@@ -1312,7 +1345,7 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
_temporal_anchoring_rule = ""
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
_template_sections = f"""{HISTORICAL_TASK_HEADING}
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("refactor the auth module")
|
||||
@@ -1359,7 +1392,7 @@ Be specific with file paths, commands, line numbers, and results.]
|
||||
- Any running processes or servers
|
||||
- Environment details that matter]
|
||||
|
||||
## In Progress
|
||||
{HISTORICAL_IN_PROGRESS_HEADING}
|
||||
[Work currently underway — what was being done when compaction fired]
|
||||
|
||||
## Blocked
|
||||
@@ -1371,14 +1404,14 @@ Be specific with file paths, commands, line numbers, and results.]
|
||||
## Resolved Questions
|
||||
[Questions the user asked that were ALREADY answered — include the answer so it is not repeated]
|
||||
|
||||
## Pending User Asks
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. If none, write "None."]
|
||||
{HISTORICAL_PENDING_ASKS_HEADING}
|
||||
[Questions or requests from the user that have NOT yet been answered or fulfilled. These are STALE — they were from the compacted turns. Write them here for reference only. The agent must NOT act on them unless the latest user message explicitly requests it. If none, write "None."]
|
||||
|
||||
## Relevant Files
|
||||
[Files read, modified, or created — with brief note on each]
|
||||
|
||||
## Remaining Work
|
||||
[What remains to be done — framed as context, not instructions]
|
||||
{HISTORICAL_REMAINING_WORK_HEADING}
|
||||
[What remains to be done — framed as STALE context for reference only. The agent must NOT resume this work unless the latest user message explicitly asks for it.]
|
||||
|
||||
## Critical Context
|
||||
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
|
||||
@@ -1753,7 +1786,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
Context compressor bug (#10896): ``_align_boundary_backward`` can pull
|
||||
``cut_idx`` past a user message when it tries to keep tool_call/result
|
||||
groups together. If the last user message ends up in the *compressed*
|
||||
middle region the LLM summariser writes it into "Pending User Asks",
|
||||
middle region the LLM summariser writes it into "Historical Pending User Asks",
|
||||
but ``SUMMARY_PREFIX`` tells the next model to respond only to user
|
||||
messages *after* the summary — so the task effectively disappears from
|
||||
the active context, causing the agent to stall, repeat completed work,
|
||||
|
||||
@@ -191,9 +191,10 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
)
|
||||
if toolset
|
||||
}
|
||||
# Coding posture demotes non-coding skill categories to names-only in
|
||||
# the index (never hidden — skill_view/skills_list reach everything,
|
||||
# and every name stays visible for memory-anchored recall).
|
||||
# Focus mode (opt-in) demotes non-coding skill categories to
|
||||
# names-only in the index (never hidden — skill_view/skills_list
|
||||
# reach everything, and every name stays visible for recall). The
|
||||
# default coding posture leaves the index untouched.
|
||||
_compact_cats = frozenset()
|
||||
try:
|
||||
from agent.coding_context import coding_compact_skill_categories
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
function fetchPublicText(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch (error) {
|
||||
reject(new Error(`Invalid URL: ${error.message}`))
|
||||
return
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
return
|
||||
}
|
||||
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const req = client.request(parsed, { method: options.method || 'GET' }, res => {
|
||||
const chunks = []
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
||||
return
|
||||
}
|
||||
resolve(text)
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
||||
})
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
@@ -29,6 +29,7 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { resolveServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
@@ -4594,15 +4595,20 @@ async function spawnPoolBackend(profile, entry) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
|
||||
rememberLog(`[boot] could not read served dashboard token for profile "${profile}": ${error.message}`)
|
||||
return token
|
||||
})
|
||||
entry.token = authToken
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
token: authToken,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4821,6 +4827,10 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
const authToken = await resolveServedDashboardToken(baseUrl, token, { rememberLog }).catch(error => {
|
||||
rememberLog(`[boot] could not read served dashboard token: ${error.message}`)
|
||||
return token
|
||||
})
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
@@ -4834,8 +4844,8 @@ async function startHermes() {
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -6111,6 +6121,111 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla
|
||||
// Search the Marketplace for color-theme extensions (empty query = top installs).
|
||||
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
|
||||
// A docs/dashboard "Send to App" button opens this URL; we route it into the
|
||||
// running app's chat composer. Three delivery paths: macOS 'open-url',
|
||||
// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
|
||||
// ---------------------------------------------------------------------------
|
||||
const HERMES_PROTOCOL = 'hermes'
|
||||
let _pendingDeepLink = null
|
||||
let _rendererReadyForDeepLink = false
|
||||
|
||||
function _extractDeepLink(argv) {
|
||||
if (!Array.isArray(argv)) return null
|
||||
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
}
|
||||
|
||||
function handleDeepLink(url) {
|
||||
if (!url || typeof url !== 'string') return
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
rememberLog(`[deeplink] ignoring malformed url: ${url}`)
|
||||
return
|
||||
}
|
||||
// hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
|
||||
const kind = parsed.hostname || ''
|
||||
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
|
||||
const params = {}
|
||||
parsed.searchParams.forEach((v, k) => {
|
||||
params[k] = v
|
||||
})
|
||||
const payload = { kind, name, params }
|
||||
|
||||
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
|
||||
_pendingDeepLink = payload
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
mainWindow.webContents.send('hermes:deep-link', payload)
|
||||
rememberLog(`[deeplink] delivered ${kind}/${name}`)
|
||||
} catch (err) {
|
||||
rememberLog(`[deeplink] delivery failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
|
||||
// a link that arrived during boot/install is flushed exactly once.
|
||||
ipcMain.handle('hermes:deep-link-ready', () => {
|
||||
_rendererReadyForDeepLink = true
|
||||
if (_pendingDeepLink) {
|
||||
const queued = _pendingDeepLink
|
||||
_pendingDeepLink = null
|
||||
handleDeepLink(
|
||||
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
|
||||
(Object.keys(queued.params).length
|
||||
? '?' + new URLSearchParams(queued.params).toString()
|
||||
: ''),
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
function registerDeepLinkProtocol() {
|
||||
try {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
// Dev: register with the electron exec path + entry script so the OS can
|
||||
// relaunch us with the URL.
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
|
||||
}
|
||||
} catch (err) {
|
||||
rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
|
||||
// second-instance argv. Without the lock a second `hermes://` launch spawns a
|
||||
// whole new app instead of routing into the running one.
|
||||
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
if (!_gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
const url = _extractDeepLink(argv)
|
||||
if (url) handleDeepLink(url)
|
||||
else if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// macOS delivers deep links via 'open-url' — register early (can fire before
|
||||
// whenReady; handleDeepLink queues until the renderer is ready).
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
@@ -6119,11 +6234,16 @@ app.whenReady().then(() => {
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
registerDeepLinkProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
registerPowerResumeListeners()
|
||||
createWindow()
|
||||
|
||||
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
||||
const _coldStartLink = _extractDeepLink(process.argv)
|
||||
if (_coldStartLink) handleDeepLink(_coldStartLink)
|
||||
|
||||
app.on('activate', () => {
|
||||
// Recreate the primary window if it's gone. Guard on mainWindow directly
|
||||
// (not just total window count) so a dock click still restores the main
|
||||
|
||||
@@ -80,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
||||
},
|
||||
onDeepLink: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:deep-link', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
|
||||
},
|
||||
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
|
||||
onWindowStateChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
|
||||
@@ -8,7 +8,7 @@ const path = require('node:path')
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -72,6 +72,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
@@ -83,6 +84,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
@@ -103,7 +105,7 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
@@ -132,6 +134,14 @@
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Hermes Protocol",
|
||||
"schemes": [
|
||||
"hermes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "assets/icon",
|
||||
"directories": {
|
||||
|
||||
@@ -1630,7 +1630,7 @@ export function ChatBar({
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck="true"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
@@ -1649,7 +1649,15 @@ export function ChatBar({
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild submitMode="ctrlEnter" tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
async function readTextPreview(filePath: string) {
|
||||
if (window.hermesDesktop.readFileText) {
|
||||
try {
|
||||
return await window.hermesDesktop.readFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
try {
|
||||
return await readDesktopFileText(filePath)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
|
||||
if (isImage) {
|
||||
// Prefer bytes the caller already handed us (a pasted/dropped
|
||||
// screenshot) over re-reading a path that may be transient/unreadable.
|
||||
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
|
||||
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
|
||||
|
||||
if (active) {
|
||||
setState({ dataUrl, loading: false })
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { PreviewPane } from './preview-pane'
|
||||
|
||||
describe('PreviewPane console state', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('does not watch backend-only remote filesystem previews locally', () => {
|
||||
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
|
||||
const onPreviewFileChanged = vi.fn(() => vi.fn())
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
hermesDesktop: {
|
||||
onPreviewFileChanged,
|
||||
watchPreviewFile
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<PreviewPane
|
||||
setTitlebarToolGroup={vi.fn()}
|
||||
target={{
|
||||
kind: 'file',
|
||||
label: 'file.txt',
|
||||
path: '/remote/file.txt',
|
||||
previewKind: 'text',
|
||||
source: '/remote/file.txt',
|
||||
url: 'file:///remote/file.txt'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(watchPreviewFile).not.toHaveBeenCalled()
|
||||
expect(onPreviewFileChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not rebuild the pane titlebar group for streamed console logs', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
|
||||
import { Bug } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -406,6 +407,7 @@ export function PreviewPane({
|
||||
useEffect(() => {
|
||||
if (
|
||||
target.kind !== 'file' ||
|
||||
isDesktopFsRemoteMode() ||
|
||||
!window.hermesDesktop?.watchPreviewFile ||
|
||||
!window.hermesDesktop?.onPreviewFileChanged
|
||||
) {
|
||||
|
||||
@@ -797,7 +797,14 @@ export function ChatSidebar({
|
||||
<SidebarMenuButton
|
||||
aria-disabled={!isInteractive}
|
||||
className={cn(
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
// no-drag: these rows sit directly under the titlebar's
|
||||
// [-webkit-app-region:drag] strips (app-shell.tsx), with only
|
||||
// 6px of clearance. Drag regions win hit-testing over DOM
|
||||
// (pointer-events can't override), and on Linux/WSLg the
|
||||
// resolved region has been observed to swallow clicks on the
|
||||
// top rows. Same carve-out as USER_BUBBLE_BASE_CLASS in
|
||||
// thread.tsx.
|
||||
'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out [-webkit-app-region:no-drag] hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
|
||||
active &&
|
||||
'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
|
||||
!isInteractive &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
|
||||
import { formatRefValue } from '../components/assistant-ui/directive-text'
|
||||
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
|
||||
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
|
||||
@@ -266,6 +267,31 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
// the shared command handler) creates the job. Signal readiness so a link
|
||||
// that arrived during boot is flushed exactly once.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
|
||||
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
|
||||
return
|
||||
}
|
||||
const slots = Object.entries(payload.params || {})
|
||||
.map(([k, v]) => {
|
||||
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
|
||||
return `${k}=${sval}`
|
||||
})
|
||||
.join(' ')
|
||||
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
|
||||
requestComposerInsert(command, { mode: 'block', target: 'main' })
|
||||
requestComposerFocus('main')
|
||||
})
|
||||
// Tell the main process the renderer is ready to receive deep links.
|
||||
void window.hermesDesktop?.signalDeepLinkReady?.()
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
@@ -25,12 +26,16 @@ import {
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
ensureDefaultWorkspaceCwd,
|
||||
setConnection,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
@@ -353,6 +358,11 @@ export function useGatewayBoot({
|
||||
progress: 97
|
||||
})
|
||||
await ensureDefaultWorkspaceCwd()
|
||||
const remoteDefault = await desktopDefaultCwd().catch(() => null)
|
||||
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
|
||||
setCurrentCwd(remoteDefault.cwd)
|
||||
setCurrentBranch(remoteDefault.branch || '')
|
||||
}
|
||||
await callbacksRef.current.refreshHermesConfig()
|
||||
|
||||
if (cancelled) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createDragDropManager, type DragDropManager } from 'dnd-core'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
|
||||
let manager: DragDropManager | null = null
|
||||
|
||||
/**
|
||||
* A single, app-lifetime react-dnd manager for the file tree.
|
||||
*
|
||||
* react-arborist mounts its own react-dnd `DndProvider` with `HTML5Backend`
|
||||
* inside every `<Tree>`. react-dnd v14 stores that provider's manager on a
|
||||
* global, ref-counted singleton context and nulls it when the count hits 0.
|
||||
* On a keyed remount (cwd / collapse changes force a fresh `<Tree>`), the
|
||||
* singleton can be torn down and recreated while the previous `HTML5Backend`
|
||||
* still owns the `window.__isReactDndHtml5Backend` setup flag — so the new
|
||||
* backend's `setup()` throws "Cannot have two HTML5 backends at the same
|
||||
* time." and trips the file-tree error boundary (it never recovers, because
|
||||
* "Try again" just remounts into the same race).
|
||||
*
|
||||
* Passing arborist a stable `dndManager` makes it skip the global-singleton
|
||||
* path entirely and reuse one backend for the lifetime of the app, so the
|
||||
* window flag is never double-claimed.
|
||||
*/
|
||||
export function getFileTreeDndManager(): DragDropManager {
|
||||
manager ??= createDragDropManager(HTML5Backend)
|
||||
|
||||
return manager
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import ignore from 'ignore'
|
||||
|
||||
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
|
||||
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
|
||||
|
||||
export type ProjectTreeEntry = HermesReadDirEntry
|
||||
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
|
||||
}
|
||||
|
||||
async function gitRootFor(start: string) {
|
||||
if (!window.hermesDesktop?.gitRoot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = clean(start)
|
||||
const key = `${desktopFsCacheKey()}:${clean(start)}`
|
||||
let cached = gitRootCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = window.hermesDesktop.gitRoot(key)
|
||||
cached = desktopGitRoot(start)
|
||||
gitRootCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
|
||||
|
||||
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
|
||||
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const listing = await window.hermesDesktop.readDir(dir)
|
||||
const listing = await readDesktopDir(dir)
|
||||
|
||||
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
|
||||
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
|
||||
|
||||
return { base: dir, ig: ignore().add(text) }
|
||||
} catch {
|
||||
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
|
||||
}
|
||||
|
||||
async function gitignoreFor(dir: string) {
|
||||
const key = clean(dir)
|
||||
const key = `${desktopFsCacheKey()}:${clean(dir)}`
|
||||
let cached = gitignoreCache.get(key)
|
||||
|
||||
if (!cached) {
|
||||
cached = readGitignore(key)
|
||||
cached = readGitignore(clean(dir))
|
||||
gitignoreCache.set(key, cached)
|
||||
}
|
||||
|
||||
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
|
||||
return { entries: [], error: 'no-bridge' }
|
||||
}
|
||||
|
||||
const result = await window.hermesDesktop.readDir(dirPath)
|
||||
const result = await readDesktopDir(dirPath)
|
||||
const entries = result?.entries ?? []
|
||||
|
||||
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
|
||||
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
|
||||
}
|
||||
|
||||
export function clearProjectDirCache(rootPath?: string) {
|
||||
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = clean(rootPath)
|
||||
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
|
||||
gitRootCache.delete(key)
|
||||
gitignoreCache.delete(key)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function clean(path: string) {
|
||||
return path.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
function parentDir(path: string) {
|
||||
const value = clean(path)
|
||||
if (value === '/') {
|
||||
return '/'
|
||||
}
|
||||
const parent = value.slice(0, value.lastIndexOf('/'))
|
||||
return parent || '/'
|
||||
}
|
||||
|
||||
function pathName(path: string) {
|
||||
return path.split('/').filter(Boolean).pop() || path
|
||||
}
|
||||
|
||||
interface PendingSelection {
|
||||
defaultPath: string
|
||||
resolve: (paths: string[]) => void
|
||||
title: string
|
||||
}
|
||||
|
||||
export function RemoteFolderPicker() {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const [pending, setPending] = useState<PendingSelection | null>(null)
|
||||
const [currentPath, setCurrentPath] = useState('/')
|
||||
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopFsRemotePicker({
|
||||
selectPaths: options =>
|
||||
new Promise(resolve => {
|
||||
const defaultPath = clean(options?.defaultPath || '/')
|
||||
setCurrentPath(defaultPath)
|
||||
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
|
||||
})
|
||||
})
|
||||
return () => setDesktopFsRemotePicker(null)
|
||||
}, [r.remotePickerTitle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
void readDesktopDir(currentPath)
|
||||
.then(result => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setEntries([])
|
||||
return
|
||||
}
|
||||
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
|
||||
})
|
||||
.catch(err => {
|
||||
if (active) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setEntries([])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [currentPath, pending])
|
||||
|
||||
const crumbs = useMemo(() => {
|
||||
const parts = clean(currentPath).split('/').filter(Boolean)
|
||||
const out = [{ label: '/', path: '/' }]
|
||||
let acc = ''
|
||||
for (const part of parts) {
|
||||
acc += `/${part}`
|
||||
out.push({ label: part, path: acc })
|
||||
}
|
||||
return out
|
||||
}, [currentPath])
|
||||
|
||||
const close = (paths: string[] = []) => {
|
||||
pending?.resolve(paths)
|
||||
setPending(null)
|
||||
setEntries([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
|
||||
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
||||
<div className="border-b border-border/70 px-4 py-3">
|
||||
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[22rem] flex-col">
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{crumbs.map((crumb, index) => (
|
||||
<button
|
||||
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
|
||||
key={crumb.path}
|
||||
onClick={() => setCurrentPath(crumb.path)}
|
||||
type="button"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
|
||||
<Codicon name="loading" size="0.8rem" spinning />
|
||||
{r.loadingFiles}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
|
||||
) : (
|
||||
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button onClick={() => close()} size="sm" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button onClick={() => close([currentPath])} size="sm">
|
||||
{r.remotePickerSelect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="folder" size="0.875rem" />
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getFileTreeDndManager } from './dnd-manager'
|
||||
import type { TreeNode } from './use-project-tree'
|
||||
|
||||
const ROW_HEIGHT = 22
|
||||
@@ -94,6 +95,7 @@ export function ProjectTree({
|
||||
disableDrag
|
||||
disableDrop
|
||||
disableEdit
|
||||
dndManager={getFileTreeDndManager()}
|
||||
height={size.height}
|
||||
indent={INDENT}
|
||||
initialOpenState={openState}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
readDir.mockReset()
|
||||
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
@@ -106,6 +111,36 @@ describe('useProjectTree', () => {
|
||||
expect(readDir).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads gitignore from the real path while caching per connection', async () => {
|
||||
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
|
||||
const gitRoot = vi.fn(async () => '/repo')
|
||||
readDir.mockImplementation(async path => {
|
||||
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
|
||||
if (path === '/repo/src') {
|
||||
return ok([
|
||||
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
|
||||
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
|
||||
])
|
||||
}
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
|
||||
|
||||
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir).toHaveBeenCalledWith('/repo')
|
||||
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
|
||||
|
||||
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
|
||||
clearProjectDirCache()
|
||||
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
|
||||
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
|
||||
})
|
||||
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('captures per-folder error code and shows an error placeholder child', async () => {
|
||||
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
|
||||
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { clearProjectDirCache, readProjectDir } from './ipc'
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -96,6 +98,7 @@ const initialState: ProjectTreeState = {
|
||||
const inflight = new Set<string>()
|
||||
const $projectTree = atom<ProjectTreeState>(initialState)
|
||||
let nextRootRequestId = 0
|
||||
let lastConnectionKey = ''
|
||||
|
||||
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
|
||||
$projectTree.set(updater($projectTree.get()))
|
||||
@@ -157,6 +160,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
|
||||
}
|
||||
|
||||
export function resetProjectTreeState() {
|
||||
lastConnectionKey = ''
|
||||
clearProjectTree()
|
||||
clearProjectDirCache()
|
||||
}
|
||||
@@ -170,6 +174,8 @@ export function resetProjectTreeState() {
|
||||
*/
|
||||
export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
const state = useStore($projectTree)
|
||||
const connection = useStore($connection)
|
||||
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
|
||||
|
||||
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
|
||||
|
||||
@@ -248,8 +254,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
|
||||
lastConnectionKey = connectionKey
|
||||
if (connectionChanged) {
|
||||
clearProjectDirCache()
|
||||
void loadRoot(cwd, { force: true })
|
||||
return
|
||||
}
|
||||
void loadRoot(cwd)
|
||||
}, [cwd])
|
||||
}, [connectionKey, cwd])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { RemoteFolderPicker } from './files/remote-picker'
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
|
||||
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
const selected = await selectDesktopPaths({
|
||||
defaultPath: hasCwd ? currentCwd : undefined,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
||||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RemoteFolderPicker />
|
||||
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
|
||||
@@ -1528,6 +1528,8 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
>
|
||||
<div
|
||||
aria-label={copy.editMessage}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
autoFocus
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
|
||||
@@ -1549,9 +1551,26 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
|
||||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck={false}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
<ComposerPrimitive.Input
|
||||
asChild
|
||||
className="sr-only"
|
||||
submitMode="ctrlEnter"
|
||||
tabIndex={-1}
|
||||
unstable_focusOnScrollToBottom={false}
|
||||
>
|
||||
<textarea
|
||||
aria-hidden
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="sr-only"
|
||||
spellCheck={false}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</ComposerPrimitive.Input>
|
||||
{staging && (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-2 left-2 inline-flex items-center gap-1 rounded-full bg-background/80 px-1.5 py-0.5 text-[0.62rem] text-muted-foreground backdrop-blur-[1px]"
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
modelVisibilityKey,
|
||||
setVisibleModels
|
||||
} from '@/store/model-visibility'
|
||||
@@ -61,10 +63,21 @@ export function ModelVisibilityDialog({
|
||||
const toggle = (provider: ModelOptionProvider, model: string) => {
|
||||
const next = new Set(effectiveVisibleKeys($visibleModels.get(), providers))
|
||||
const key = modelVisibilityKey(provider.slug, model)
|
||||
const sentinel = emptyProviderSentinelKey(provider.slug)
|
||||
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
|
||||
// Check if this was the last real model for this provider.
|
||||
const remainingForProvider = [...next].some(
|
||||
k => k.startsWith(`${provider.slug}::`) && !isProviderSentinel(k)
|
||||
)
|
||||
|
||||
if (!remainingForProvider) {
|
||||
next.add(sentinel)
|
||||
}
|
||||
} else {
|
||||
next.delete(sentinel)
|
||||
next.add(key)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
@@ -75,6 +75,10 @@ declare global {
|
||||
}
|
||||
onClosePreviewRequested?: (callback: () => void) => () => void
|
||||
onOpenUpdatesRequested?: (callback: () => void) => () => void
|
||||
onDeepLink?: (
|
||||
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
|
||||
@@ -1532,6 +1532,9 @@ export const en: Translations = {
|
||||
terminal: 'Terminal',
|
||||
noFolderSelected: 'No folder selected',
|
||||
changeCwdTitle: 'Change working directory',
|
||||
remotePickerTitle: 'Choose remote folder',
|
||||
remotePickerDescription: 'Browse folders on the connected backend.',
|
||||
remotePickerSelect: 'Select folder',
|
||||
folderTip: cwd => `${cwd} — click to change folder`,
|
||||
openFolder: 'Open folder',
|
||||
refreshTree: 'Refresh tree',
|
||||
|
||||
@@ -1665,6 +1665,9 @@ export const ja = defineLocale({
|
||||
terminal: 'ターミナル',
|
||||
noFolderSelected: 'フォルダーが選択されていません',
|
||||
changeCwdTitle: '作業ディレクトリを変更',
|
||||
remotePickerTitle: 'リモートフォルダーを選択',
|
||||
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
|
||||
remotePickerSelect: 'フォルダーを選択',
|
||||
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
|
||||
openFolder: 'フォルダーを開く',
|
||||
refreshTree: 'ツリーを更新',
|
||||
|
||||
@@ -1194,6 +1194,9 @@ export interface Translations {
|
||||
terminal: string
|
||||
noFolderSelected: string
|
||||
changeCwdTitle: string
|
||||
remotePickerTitle: string
|
||||
remotePickerDescription: string
|
||||
remotePickerSelect: string
|
||||
folderTip: (cwd: string) => string
|
||||
openFolder: string
|
||||
refreshTree: string
|
||||
|
||||
@@ -1626,6 +1626,9 @@ export const zhHant = defineLocale({
|
||||
terminal: '終端機',
|
||||
noFolderSelected: '未選擇資料夾',
|
||||
changeCwdTitle: '變更工作目錄',
|
||||
remotePickerTitle: '選擇遠端資料夾',
|
||||
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
|
||||
remotePickerSelect: '選擇資料夾',
|
||||
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
|
||||
openFolder: '開啟資料夾',
|
||||
refreshTree: '重新整理檔案樹',
|
||||
|
||||
@@ -1712,6 +1712,9 @@ export const zh: Translations = {
|
||||
terminal: '终端',
|
||||
noFolderSelected: '未选择文件夹',
|
||||
changeCwdTitle: '更改工作目录',
|
||||
remotePickerTitle: '选择远程文件夹',
|
||||
remotePickerDescription: '浏览已连接后端上的文件夹。',
|
||||
remotePickerSelect: '选择文件夹',
|
||||
folderTip: cwd => `${cwd} — 点击更改文件夹`,
|
||||
openFolder: '打开文件夹',
|
||||
refreshTree: '刷新文件树',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import {
|
||||
desktopDefaultCwd,
|
||||
desktopGitRoot,
|
||||
readDesktopDir,
|
||||
readDesktopFileDataUrl,
|
||||
readDesktopFileText,
|
||||
selectDesktopPaths,
|
||||
setDesktopFsRemotePicker
|
||||
} from './desktop-fs'
|
||||
|
||||
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
|
||||
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
|
||||
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
|
||||
const gitRoot = vi.fn(async () => '/local')
|
||||
const selectPaths = vi.fn(async () => ['/local'])
|
||||
const api = vi.fn(async ({ path }: { path: string }) => {
|
||||
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
|
||||
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
|
||||
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
|
||||
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
|
||||
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
|
||||
throw new Error(`unexpected path ${path}`)
|
||||
})
|
||||
|
||||
function stubBridge() {
|
||||
vi.stubGlobal('window', {
|
||||
hermesDesktop: {
|
||||
api,
|
||||
gitRoot,
|
||||
readDir,
|
||||
readFileDataUrl,
|
||||
readFileText,
|
||||
selectPaths
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('desktop filesystem facade', () => {
|
||||
beforeEach(() => {
|
||||
stubBridge()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
$connection.set(null)
|
||||
setDesktopFsRemotePicker(null)
|
||||
})
|
||||
|
||||
it('uses local Electron filesystem methods in local mode', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
|
||||
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
|
||||
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
|
||||
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
|
||||
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
|
||||
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
|
||||
|
||||
expect(readDir).toHaveBeenCalledWith('/work')
|
||||
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
|
||||
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
|
||||
expect(gitRoot).toHaveBeenCalledWith('/work')
|
||||
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
|
||||
expect(api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
|
||||
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
|
||||
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
|
||||
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
|
||||
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
|
||||
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
|
||||
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
|
||||
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
|
||||
expect(readDir).not.toHaveBeenCalled()
|
||||
expect(readFileText).not.toHaveBeenCalled()
|
||||
expect(readFileDataUrl).not.toHaveBeenCalled()
|
||||
expect(gitRoot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the registered in-app directory picker in remote mode', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
|
||||
'/remote/project'
|
||||
])
|
||||
|
||||
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat the remote directory picker as a general file picker', async () => {
|
||||
const remoteSelect = vi.fn(async () => ['/remote/project'])
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
|
||||
|
||||
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
|
||||
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
|
||||
|
||||
expect(remoteSelect).not.toHaveBeenCalled()
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
|
||||
|
||||
export interface DesktopFsRemotePicker {
|
||||
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
|
||||
}
|
||||
|
||||
let remotePicker: DesktopFsRemotePicker | null = null
|
||||
|
||||
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
|
||||
remotePicker = next
|
||||
}
|
||||
|
||||
function connectionCacheKey(connection: HermesConnection | null) {
|
||||
if (!connection) {
|
||||
return 'local:'
|
||||
}
|
||||
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
|
||||
}
|
||||
|
||||
export function desktopFsCacheKey() {
|
||||
return connectionCacheKey($connection.get())
|
||||
}
|
||||
|
||||
export function isDesktopFsRemoteMode() {
|
||||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
function fsPath(endpoint: string, filePath: string) {
|
||||
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
|
||||
}
|
||||
|
||||
function bridge() {
|
||||
const desktop = window.hermesDesktop
|
||||
if (!desktop) {
|
||||
throw new Error('Hermes Desktop bridge is unavailable')
|
||||
}
|
||||
return desktop
|
||||
}
|
||||
|
||||
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readDir(path)
|
||||
}
|
||||
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileText(path)
|
||||
}
|
||||
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
|
||||
}
|
||||
|
||||
export async function readDesktopFileDataUrl(path: string): Promise<string> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.readFileDataUrl(path)
|
||||
}
|
||||
|
||||
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
|
||||
return typeof result === 'string' ? result : result.dataUrl || ''
|
||||
}
|
||||
|
||||
export async function desktopGitRoot(path: string): Promise<string | null> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.gitRoot ? desktop.gitRoot(path) : null
|
||||
}
|
||||
|
||||
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
|
||||
return result.root
|
||||
}
|
||||
|
||||
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
|
||||
}
|
||||
|
||||
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
|
||||
const desktop = bridge()
|
||||
if (!isDesktopFsRemoteMode()) {
|
||||
return desktop.selectPaths(options)
|
||||
}
|
||||
if (!options?.directories || options.multiple !== false) {
|
||||
return []
|
||||
}
|
||||
return remotePicker ? remotePicker.selectPaths(options) : []
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
|
||||
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
|
||||
return target
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await readDesktopFileText(target.path || target.source)
|
||||
return {
|
||||
...target,
|
||||
binary: result.binary,
|
||||
byteSize: result.byteSize,
|
||||
language: result.language || target.language,
|
||||
large: (result.byteSize ?? 0) > 512 * 1024,
|
||||
mimeType: result.mimeType
|
||||
}
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeOrLocalPreviewTarget(
|
||||
rawTarget: string,
|
||||
cwd?: string | null
|
||||
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
|
||||
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
|
||||
|
||||
if (normalized) {
|
||||
return normalized
|
||||
return enrichPreviewTarget(normalized)
|
||||
}
|
||||
} catch {
|
||||
// Running Electron may still have the old HTML-only preview IPC. Fall
|
||||
// through to renderer-side local classification so text/images still open.
|
||||
}
|
||||
|
||||
return localPreviewTarget(rawTarget, cwd)
|
||||
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ModelOptionProvider } from '@/types/hermes'
|
||||
|
||||
import { effectiveVisibleKeys, modelVisibilityKey } from './model-visibility'
|
||||
import {
|
||||
effectiveVisibleKeys,
|
||||
emptyProviderSentinelKey,
|
||||
isProviderSentinel,
|
||||
modelVisibilityKey
|
||||
} from './model-visibility'
|
||||
|
||||
const provider = (slug: string, models: string[]): ModelOptionProvider => ({
|
||||
models,
|
||||
@@ -34,4 +39,48 @@ describe('model visibility', () => {
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'qwen3:latest'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('local-ollama', 'llama3.2:latest'))).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves hidden-provider sentinel without re-adding defaults', () => {
|
||||
// User explicitly hid all models for "nous" — sentinel marks this choice.
|
||||
const stored = new Set([emptyProviderSentinelKey('nous')])
|
||||
|
||||
const visible = effectiveVisibleKeys(stored, [
|
||||
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
|
||||
provider('ollama', ['qwen3:latest'])
|
||||
])
|
||||
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(false)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
||||
// Sentinel itself is stripped from the result.
|
||||
expect(visible.has(emptyProviderSentinelKey('nous'))).toBe(false)
|
||||
// Other providers still get defaults.
|
||||
expect(visible.has(modelVisibilityKey('ollama', 'qwen3:latest'))).toBe(true)
|
||||
})
|
||||
|
||||
it('restores model when toggling on after hiding all', () => {
|
||||
// Simulates: user hid all "nous" models, then toggles one back on.
|
||||
const stored = new Set([
|
||||
emptyProviderSentinelKey('nous'),
|
||||
modelVisibilityKey('ollama', 'qwen3:latest')
|
||||
])
|
||||
|
||||
// After toggle: sentinel removed, one model added.
|
||||
const afterToggle = new Set(stored)
|
||||
afterToggle.delete(emptyProviderSentinelKey('nous'))
|
||||
afterToggle.add(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))
|
||||
|
||||
const visible = effectiveVisibleKeys(afterToggle, [
|
||||
provider('nous', ['hermes-3-llama-3.1-70b', 'hermes-3-llama-3.1-8b']),
|
||||
provider('ollama', ['qwen3:latest'])
|
||||
])
|
||||
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-70b'))).toBe(true)
|
||||
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
|
||||
})
|
||||
|
||||
it('sentinel key helper produces correct format', () => {
|
||||
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
|
||||
expect(isProviderSentinel('openai::')).toBe(true)
|
||||
expect(isProviderSentinel('openai::gpt-4o')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,19 @@ export const DEFAULT_VISIBLE_PER_PROVIDER = 50
|
||||
* that contain a single colon, e.g. `model:tag`). */
|
||||
export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
|
||||
|
||||
/** Sentinel key suffix stored when the user explicitly hides ALL models for a
|
||||
* provider. Distinguishes "user hid everything" from "never customized" so
|
||||
* `effectiveVisibleKeys` does not re-add defaults for that provider. */
|
||||
export const EMPTY_PROVIDER_SENTINEL = ''
|
||||
|
||||
/** Build the sentinel key for a provider whose last model was toggled off. */
|
||||
export const emptyProviderSentinelKey = (provider: string): string =>
|
||||
modelVisibilityKey(provider, EMPTY_PROVIDER_SENTINEL)
|
||||
|
||||
/** Check whether a stored key is a provider-hidden sentinel. */
|
||||
export const isProviderSentinel = (key: string): boolean =>
|
||||
key.endsWith('::')
|
||||
|
||||
/** A model and its optional `…-fast` sibling, collapsed into one logical row.
|
||||
* `id` is the canonical (base) model; `fastId` is the fast variant if present. */
|
||||
export interface ModelFamily {
|
||||
@@ -116,9 +129,12 @@ export function effectiveVisibleKeys(
|
||||
|
||||
for (const provider of providers) {
|
||||
const providerPrefix = `${provider.slug}::`
|
||||
const hasStoredProvider = [...stored].some(key => key.startsWith(providerPrefix))
|
||||
const hasStoredProvider = [...stored].some(
|
||||
key => key.startsWith(providerPrefix) && !isProviderSentinel(key)
|
||||
)
|
||||
const hasSentinel = stored.has(emptyProviderSentinelKey(provider.slug))
|
||||
|
||||
if (hasStoredProvider) {
|
||||
if (hasStoredProvider || hasSentinel) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -129,5 +145,12 @@ export function effectiveVisibleKeys(
|
||||
}
|
||||
}
|
||||
|
||||
// Strip sentinel keys — they are bookkeeping, not real visibility entries.
|
||||
for (const key of [...next]) {
|
||||
if (isProviderSentinel(key)) {
|
||||
next.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$workingSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
workspaceCwdForNewSession
|
||||
@@ -184,9 +186,12 @@ describe('mergeSessionPage', () => {
|
||||
describe('workspaceCwdForNewSession', () => {
|
||||
afterEach(() => {
|
||||
applyConfiguredDefaultProjectDir(null)
|
||||
$connection.set(null)
|
||||
$currentCwd.set('')
|
||||
$activeSessionId.set(null)
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
|
||||
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
|
||||
})
|
||||
|
||||
it('prefers the configured default over the sticky remembered workspace', () => {
|
||||
@@ -216,6 +221,26 @@ describe('workspaceCwdForNewSession', () => {
|
||||
expect($currentCwd.get()).toBe('/live/session/path')
|
||||
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
|
||||
})
|
||||
|
||||
it('keeps remote workspace memory separate from local and other remotes', () => {
|
||||
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
|
||||
$currentCwd.set('/live/session/path')
|
||||
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
|
||||
|
||||
expect(workspaceCwdForNewSession()).toBe('')
|
||||
|
||||
setCurrentCwd('/backend/project-a')
|
||||
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
|
||||
|
||||
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
|
||||
expect(workspaceCwdForNewSession()).toBe('')
|
||||
|
||||
setCurrentCwd('/backend/project-b')
|
||||
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
|
||||
|
||||
$connection.set(null)
|
||||
expect(workspaceCwdForNewSession()).toBe('/local/project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentlySettledSessionIds', () => {
|
||||
|
||||
@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
|
||||
|
||||
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
|
||||
|
||||
// Cached copy of Settings → Sessions → Default project directory. The main
|
||||
// process persists this in project-dir.json, but the renderer must also honor it
|
||||
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
|
||||
// wins and new sessions ignore the user's explicit picker choice.
|
||||
let configuredDefaultProjectDir = ''
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
|
||||
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return WORKSPACE_CWD_KEY
|
||||
}
|
||||
|
||||
const base = encodeURIComponent(connection.baseUrl || 'remote')
|
||||
const profile = encodeURIComponent(connection.profile || 'default')
|
||||
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
|
||||
}
|
||||
|
||||
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
|
||||
|
||||
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
|
||||
|
||||
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const remembered = getRememberedWorkspaceCwd()
|
||||
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
seedLiveCwd(remembered)
|
||||
return
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
const { cwd } = await sanitize(configured)
|
||||
seedLiveCwd(cwd)
|
||||
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
|
||||
seedLiveCwd(cwd)
|
||||
if (remembered) {
|
||||
const { cwd } = await sanitize(remembered)
|
||||
seedLiveCwd(cwd)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
|
||||
@@ -238,15 +253,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
|
||||
|
||||
export const setCurrentCwd = (next: Updater<string>) => {
|
||||
updateAtom($currentCwd, next)
|
||||
// Keep localStorage in sync with the atom: a real folder is remembered, an
|
||||
// empty cwd clears the key (|| null → removeItem).
|
||||
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
|
||||
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
|
||||
}
|
||||
|
||||
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
|
||||
* fall back to the sticky last-used folder, then whatever is already live. */
|
||||
export const workspaceCwdForNewSession = (): string =>
|
||||
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
|
||||
export const workspaceCwdForNewSession = (): string => {
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
return getRememberedWorkspaceCwd()
|
||||
}
|
||||
|
||||
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
|
||||
}
|
||||
|
||||
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
|
||||
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
|
||||
|
||||
@@ -3504,6 +3504,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# the next submitted input, whether it's the selection or anything
|
||||
# else). See #34584.
|
||||
self._pending_resume_sessions = None
|
||||
# One-shot agent seed set by a slash handler (e.g. /blueprint <name>)
|
||||
# that wants its output run as the next agent turn. Consumed and cleared
|
||||
# by the interactive loop immediately after process_command() returns.
|
||||
self._pending_agent_seed = None
|
||||
self._secret_state = None
|
||||
self._secret_deadline = 0
|
||||
self._spinner_text: str = "" # thinking spinner text for TUI
|
||||
@@ -7409,6 +7413,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self.save_conversation()
|
||||
elif canonical == "cron":
|
||||
self._handle_cron_command(cmd_original)
|
||||
elif canonical == "suggestions":
|
||||
self._handle_suggestions_command(cmd_original)
|
||||
elif canonical == "blueprint":
|
||||
self._handle_blueprint_command(cmd_original)
|
||||
elif canonical == "curator":
|
||||
self._handle_curator_command(cmd_original)
|
||||
elif canonical == "kanban":
|
||||
@@ -12827,7 +12835,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# session. Without this guard a KeyboardInterrupt unwinds
|
||||
# to the outer prompt_toolkit loop and the session dies.
|
||||
_cprint("\n[dim]Command interrupted.[/dim]")
|
||||
continue
|
||||
continue
|
||||
# A slash handler may set a one-shot pending seed (e.g.
|
||||
# /blueprint <name>) to be run as the next agent turn.
|
||||
# If present, fall through to the chat path with the seed
|
||||
# as the user message instead of looping back to idle.
|
||||
_seed = getattr(self, "_pending_agent_seed", None)
|
||||
if _seed:
|
||||
self._pending_agent_seed = None
|
||||
user_input = _seed
|
||||
else:
|
||||
continue
|
||||
|
||||
# Expand paste references back to full content
|
||||
_paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
"""Automation Blueprints — parameterized automation blueprints with typed slots.
|
||||
|
||||
A *blueprint* is a one-place definition of an automation that every surface
|
||||
renders natively:
|
||||
|
||||
* Dashboard / GUI app -> a form (one field per slot)
|
||||
* CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
|
||||
* Agent -> a seed prompt; it asks for any blank/ambiguous slot
|
||||
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
|
||||
|
||||
The single source of truth is the slot schema below. ``blueprint_form_schema``
|
||||
emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
|
||||
one-line command; ``fill_blueprint`` validates user-supplied values and turns a
|
||||
blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
|
||||
engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
|
||||
split both consume this same module.
|
||||
|
||||
Design choice: users never type raw cron. A blueprint carries a fixed recurrence
|
||||
in ``schedule_template`` and parameterizes only the human-friendly parts
|
||||
(time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
|
||||
slot named ``schedule`` that passes through verbatim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
__all__ = [
|
||||
"BlueprintSlot",
|
||||
"AutomationBlueprint",
|
||||
"CATALOG",
|
||||
"get_blueprint",
|
||||
"blueprint_form_schema",
|
||||
"blueprint_slash_command",
|
||||
"blueprint_deeplink",
|
||||
"blueprint_catalog_entry",
|
||||
"fill_blueprint",
|
||||
"BlueprintFillError",
|
||||
"WEEKDAY_PRESETS",
|
||||
]
|
||||
|
||||
|
||||
class BlueprintFillError(ValueError):
|
||||
"""Raised when supplied slot values fail validation."""
|
||||
|
||||
|
||||
# Slot types the renderers understand.
|
||||
_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
|
||||
|
||||
# Named weekday recurrences -> cron day-of-week field.
|
||||
WEEKDAY_PRESETS: Dict[str, str] = {
|
||||
"everyday": "*",
|
||||
"weekdays": "1-5",
|
||||
"weekends": "0,6",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BlueprintSlot:
|
||||
"""A single fillable field on a blueprint."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
label: str
|
||||
default: Any = None
|
||||
options: tuple = () # for type="enum": allowed values
|
||||
optional: bool = False
|
||||
help: str = ""
|
||||
# When False, ``options`` are suggestions rather than a closed set —
|
||||
# any value is accepted (e.g. the deliver slot, where the real set of
|
||||
# valid platforms depends on the user's configured gateways and is
|
||||
# validated downstream by the cron scheduler).
|
||||
strict: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.type not in _SLOT_TYPES:
|
||||
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutomationBlueprint:
|
||||
"""A parameterized automation blueprint."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
description: str
|
||||
category: str
|
||||
# Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
|
||||
# Placeholders are filled from resolved slot values (time -> minute/hour,
|
||||
# weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
|
||||
schedule_template: str
|
||||
# Seed instruction for the agent / the cron job prompt; may contain {slot}s.
|
||||
prompt_template: str
|
||||
slots: List[BlueprintSlot] = field(default_factory=list)
|
||||
deliver_default: str = "origin"
|
||||
skills: tuple = () # skills the job loads before running
|
||||
tags: tuple = ()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curated in-repo catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
|
||||
name="time", type="time", label="What time?", default=default,
|
||||
help="24h local time, e.g. 08:00",
|
||||
)
|
||||
_DELIVER = BlueprintSlot(
|
||||
name="deliver", type="enum", label="Where to deliver?",
|
||||
default="origin", options=("origin", "local", "telegram", "discord", "email"),
|
||||
optional=False, strict=False,
|
||||
help="origin = the chat you set this up from (or your configured home "
|
||||
"channel when created from the dashboard); local = save only, no message; "
|
||||
"or any connected platform name",
|
||||
)
|
||||
|
||||
|
||||
CATALOG: List[AutomationBlueprint] = [
|
||||
AutomationBlueprint(
|
||||
key="morning-brief",
|
||||
title="Morning briefing",
|
||||
description="A short daily briefing: today's calendar, weather, and "
|
||||
"anything urgent waiting on you.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * *",
|
||||
prompt_template=(
|
||||
"Produce a concise morning briefing for the user: today's calendar "
|
||||
"events, the local weather, and any urgent items. Keep it short and "
|
||||
"scannable. If no data sources are connected, give a brief "
|
||||
"good-morning with the date and offer to connect calendar/email."
|
||||
),
|
||||
slots=[_TIME("08:00"), _DELIVER],
|
||||
tags=("daily", "briefing"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="important-mail",
|
||||
title="Important-mail monitor",
|
||||
description="Check your inbox periodically and ping you ONLY about mail "
|
||||
"that actually needs attention.",
|
||||
category="email",
|
||||
schedule_template="*/{interval_min} * * * *",
|
||||
prompt_template=(
|
||||
"Check the user's inbox for new messages since the last run. Surface "
|
||||
"ONLY mail matching: {criteria}. Score candidates with the urgency "
|
||||
"classifier and deliver only what clears the bar; if nothing does, "
|
||||
"respond with [SILENT]. Requires a connected mail source; if none is "
|
||||
"configured, explain how to connect one and stop."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="interval_min", type="enum", label="How often?",
|
||||
default="30", options=("15", "30", "60"),
|
||||
help="minutes between checks",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="criteria", type="text",
|
||||
label="Only notify me if the mail…",
|
||||
default="needs a reply today, is from my manager or family, "
|
||||
"or mentions a deadline",
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("email", "monitor"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="weekly-review",
|
||||
title="Weekly review",
|
||||
description="A weekly recap: what got done, what's still open, and "
|
||||
"what's coming up.",
|
||||
category="weekly",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Produce a weekly review for the user: what was accomplished this "
|
||||
"week, still-open items, and next week's calendar. Pull from "
|
||||
"connected sources. Keep it tight."
|
||||
),
|
||||
slots=[
|
||||
_TIME("18:00"),
|
||||
BlueprintSlot(
|
||||
name="day", type="enum", label="Which day?",
|
||||
default="sunday",
|
||||
options=("sunday", "monday", "friday", "saturday"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("weekly", "review"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="workday-start",
|
||||
title="Workday start reminder",
|
||||
description="A weekday nudge with your agenda and top priorities.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * 1-5",
|
||||
prompt_template=(
|
||||
"Give the user a brief weekday start-of-day nudge: today's calendar "
|
||||
"and the 1-3 highest-priority things to focus on, inferred from "
|
||||
"recent context and any task tools. Encouraging, short, one message."
|
||||
),
|
||||
slots=[_TIME("09:00"), _DELIVER],
|
||||
tags=("daily", "focus"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="custom-reminder",
|
||||
title="Custom reminder",
|
||||
description="A recurring reminder in your own words, on your schedule.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template="Remind the user: {what}",
|
||||
slots=[
|
||||
BlueprintSlot(name="what", type="text", label="Remind me to…",
|
||||
default="take a break and stretch"),
|
||||
_TIME("14:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("reminder",),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="evening-winddown",
|
||||
title="Evening wind-down",
|
||||
description="An end-of-day check-in: tomorrow's calendar at a glance "
|
||||
"and anything you should prep tonight.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * *",
|
||||
prompt_template=(
|
||||
"Give the user a short evening wind-down: tomorrow's calendar, any "
|
||||
"early commitments to prep for, and one gentle nudge to wrap up "
|
||||
"loose ends from today. Keep it calm and brief — one message. If no "
|
||||
"calendar is connected, just offer a friendly sign-off and the "
|
||||
"weather for tomorrow."
|
||||
),
|
||||
slots=[_TIME("21:00"), _DELIVER],
|
||||
tags=("daily", "evening"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="news-digest",
|
||||
title="Topic news digest",
|
||||
description="A recurring digest on a topic you care about — deduped "
|
||||
"against what was already sent, so only genuinely new items land.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Search the web for new and noteworthy items about: {topic}. "
|
||||
"Dedupe against what you sent in previous runs — only include "
|
||||
"genuinely new developments. Deliver a tight digest of at most "
|
||||
"{count} bullets, each one line with a link. If nothing new since "
|
||||
"last run, respond with [SILENT]."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="topic", type="text", label="What topic?",
|
||||
default="AI and technology",
|
||||
help="a subject, product, person, or search phrase",
|
||||
),
|
||||
_TIME("18:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="weekdays",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="count", type="enum", label="How many bullets?",
|
||||
default="5", options=("3", "5", "8"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("digest", "research"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="bill-renewal-watch",
|
||||
title="Bills & renewals reminder",
|
||||
description="A heads-up before a recurring payment, subscription "
|
||||
"renewal, or due date — so nothing auto-charges by surprise.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Remind the user about an upcoming payment or renewal: {what}. "
|
||||
"Phrase it as an actionable heads-up (e.g. 'review or cancel before "
|
||||
"it renews'), not just a notification. One short message."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="what", type="text", label="What's due?",
|
||||
default="my streaming subscription renews soon",
|
||||
),
|
||||
_TIME("10:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("reminder", "finance"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="habit-checkin",
|
||||
title="Habit check-in",
|
||||
description="A recurring nudge to keep a habit on track and reflect "
|
||||
"on whether you did it.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Nudge the user about their habit: {habit}. Ask whether they did it "
|
||||
"today, keep it warm and non-judgmental, and offer a one-line word "
|
||||
"of encouragement. One short message."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="habit", type="text", label="Which habit?",
|
||||
default="20 minutes of reading",
|
||||
),
|
||||
_TIME("20:00"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("habit", "wellbeing"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="hydration-move",
|
||||
title="Hydration & movement nudge",
|
||||
description="A periodic nudge during the day to drink water, stand up, "
|
||||
"and stretch.",
|
||||
category="general",
|
||||
# NOTE: cron minute-field steps (*/90) wrap per hour — */90 and */120
|
||||
# both degrade to hourly. Use an hour-field step instead so the chosen
|
||||
# cadence is what actually fires.
|
||||
schedule_template="0 {start_hour}-{end_hour}/{interval_hours} * * 1-5",
|
||||
prompt_template=(
|
||||
"Send the user a brief, friendly nudge to drink some water, stand "
|
||||
"up, and stretch for a moment. Vary the wording each time so it "
|
||||
"doesn't feel robotic. One short line."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="interval_hours", type="enum", label="How often?",
|
||||
default="1", options=("1", "2", "3"),
|
||||
help="hours between nudges",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="start_hour", type="enum", label="Start hour",
|
||||
default="9", options=("7", "8", "9", "10"),
|
||||
help="first hour of the active window (24h)",
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="end_hour", type="enum", label="End hour",
|
||||
default="17", options=("16", "17", "18", "19"),
|
||||
help="last hour of the active window (24h)",
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("wellbeing", "focus"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="meal-plan",
|
||||
title="Weekly meal plan",
|
||||
description="A weekly meal plan plus a consolidated grocery list, "
|
||||
"tuned to your diet and how much time you have to cook.",
|
||||
category="weekly",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Build the user a meal plan for the coming week: {meals} per day, "
|
||||
"suited to a {diet} diet and roughly {effort} cooking effort. "
|
||||
"Include a consolidated grocery list grouped by aisle. Keep blueprints "
|
||||
"simple and skimmable."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="diet", type="enum", label="Diet?",
|
||||
default="no restrictions",
|
||||
options=("no restrictions", "vegetarian", "vegan",
|
||||
"high-protein", "low-carb"),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="meals", type="enum", label="Meals per day?",
|
||||
default="dinner only",
|
||||
options=("dinner only", "lunch and dinner", "all three"),
|
||||
),
|
||||
BlueprintSlot(
|
||||
name="effort", type="enum", label="Cooking effort?",
|
||||
default="quick", options=("quick", "medium", "ambitious"),
|
||||
),
|
||||
_TIME("17:00"),
|
||||
BlueprintSlot(
|
||||
name="day", type="enum", label="Which day?",
|
||||
default="sunday",
|
||||
options=("sunday", "monday", "friday", "saturday"),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("weekly", "food"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="learn-daily",
|
||||
title="Daily learning drip",
|
||||
description="One bite-sized lesson a day on a topic you want to learn, "
|
||||
"building progressively over time.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Teach the user one bite-sized lesson about: {topic}. Build on "
|
||||
"earlier lessons so it progresses rather than repeating. Keep it to "
|
||||
"a couple of short paragraphs with one concrete example, and end "
|
||||
"with a single question to check understanding."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="topic", type="text", label="Learn about…",
|
||||
default="Spanish vocabulary",
|
||||
),
|
||||
_TIME("08:30"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="weekdays",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("learning", "daily"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="gratitude-journal",
|
||||
title="Gratitude & reflection prompt",
|
||||
description="A gentle evening prompt to reflect on the day and note "
|
||||
"what went well.",
|
||||
category="general",
|
||||
schedule_template="{minute} {hour} * * {dow}",
|
||||
prompt_template=(
|
||||
"Send the user a short, warm reflection prompt for the end of the "
|
||||
"day — invite them to note one thing that went well, one thing they "
|
||||
"are grateful for, and one small win. If they reply, acknowledge it "
|
||||
"kindly. One message."
|
||||
),
|
||||
slots=[
|
||||
_TIME("21:30"),
|
||||
BlueprintSlot(
|
||||
name="recurrence", type="weekdays", label="Repeat on",
|
||||
default="everyday",
|
||||
options=tuple(WEEKDAY_PRESETS.keys()),
|
||||
),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("wellbeing", "reflection"),
|
||||
),
|
||||
AutomationBlueprint(
|
||||
key="on-this-day",
|
||||
title="On-this-day discovery",
|
||||
description="A daily dose of curiosity: a notable historical event, "
|
||||
"fact, or word for the day.",
|
||||
category="daily",
|
||||
schedule_template="{minute} {hour} * * *",
|
||||
prompt_template=(
|
||||
"Give the user one interesting '{flavor}' item for today — keep it "
|
||||
"short, surprising, and genuinely interesting. One or two sentences, "
|
||||
"no filler."
|
||||
),
|
||||
slots=[
|
||||
BlueprintSlot(
|
||||
name="flavor", type="enum", label="What kind?",
|
||||
default="on this day in history",
|
||||
options=("on this day in history", "word of the day",
|
||||
"science fact", "quote of the day"),
|
||||
),
|
||||
_TIME("07:30"),
|
||||
_DELIVER,
|
||||
],
|
||||
tags=("daily", "curiosity"),
|
||||
),
|
||||
]
|
||||
|
||||
_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
|
||||
|
||||
|
||||
def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
|
||||
return _CATALOG_BY_KEY.get(key)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Renderers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
|
||||
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
|
||||
return {
|
||||
"key": blueprint.key,
|
||||
"title": blueprint.title,
|
||||
"description": blueprint.description,
|
||||
"category": blueprint.category,
|
||||
"tags": list(blueprint.tags),
|
||||
"fields": [
|
||||
{
|
||||
"name": s.name,
|
||||
"type": s.type,
|
||||
"label": s.label,
|
||||
"default": s.default,
|
||||
"options": list(s.options),
|
||||
"optional": s.optional,
|
||||
"strict": s.strict,
|
||||
"help": s.help,
|
||||
}
|
||||
for s in blueprint.slots
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the flattened ``/blueprint <key> slot=val …`` command string.
|
||||
|
||||
Uses each slot's default when ``values`` is omitted, so the docs/dashboard
|
||||
can show a ready-to-paste command. Free-text slots are quoted.
|
||||
"""
|
||||
values = values or {}
|
||||
parts = [f"/blueprint {blueprint.key}"]
|
||||
for s in blueprint.slots:
|
||||
val = values.get(s.name, s.default)
|
||||
if val is None or val == "":
|
||||
if s.optional:
|
||||
continue
|
||||
val = ""
|
||||
sval = str(val)
|
||||
if s.type == "text" or " " in sval:
|
||||
sval = '"' + sval.replace('"', '\\"') + '"'
|
||||
parts.append(f"{s.name}={sval}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
|
||||
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
values = values or {}
|
||||
query = {}
|
||||
for s in blueprint.slots:
|
||||
val = values.get(s.name, s.default)
|
||||
if val not in (None, ""):
|
||||
query[s.name] = str(val)
|
||||
qs = ("?" + urlencode(query)) if query else ""
|
||||
return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
|
||||
|
||||
|
||||
def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
|
||||
"""A short human-readable description of when a blueprint runs (defaults)."""
|
||||
sched = blueprint.schedule_template
|
||||
if sched.startswith("*/"):
|
||||
iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
|
||||
every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
|
||||
return f"every {every} minutes"
|
||||
if "{interval_hours}" in sched:
|
||||
iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
|
||||
every = str((iv.default if iv else None) or "1")
|
||||
scope = "weekdays, " if "* * 1-5" in sched else ""
|
||||
return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
|
||||
time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
|
||||
when = time_slot.default if time_slot else None
|
||||
if "* * 1-5" in sched:
|
||||
return f"weekdays at {when}" if when else "every weekday"
|
||||
if "{dow}" in sched:
|
||||
day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
|
||||
scope = (day_slot.default if day_slot else "") or ""
|
||||
if scope and when:
|
||||
return f"{scope} at {when}"
|
||||
return f"at {when}" if when else "on a schedule"
|
||||
if when:
|
||||
return f"daily at {when}"
|
||||
return "on a schedule"
|
||||
|
||||
|
||||
def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
|
||||
"""Unified serializable shape for a blueprint — used by the docs generator
|
||||
and the dashboard API. Combines the form schema, the ready-to-paste slash
|
||||
command, the deep-link URL, and a human-readable schedule.
|
||||
"""
|
||||
return {
|
||||
**blueprint_form_schema(blueprint),
|
||||
"schedule": blueprint.schedule_template,
|
||||
"scheduleHuman": _humanize_schedule(blueprint),
|
||||
"command": blueprint_slash_command(blueprint),
|
||||
"appUrl": blueprint_deeplink(blueprint),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fill + validate + translate to a create_job spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
|
||||
_DAY_TO_DOW = {
|
||||
"sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
|
||||
"thursday": "4", "friday": "5", "saturday": "6",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
|
||||
"""Fill the schedule_template placeholders from resolved slot values."""
|
||||
sched = blueprint.schedule_template
|
||||
|
||||
# A free-text `schedule` slot passes through verbatim (full flexibility).
|
||||
if "schedule" in values and values["schedule"]:
|
||||
return str(values["schedule"])
|
||||
|
||||
repl: Dict[str, str] = {}
|
||||
|
||||
# time -> minute/hour
|
||||
time_val = values.get("time")
|
||||
if "{minute}" in sched or "{hour}" in sched:
|
||||
if not time_val:
|
||||
raise BlueprintFillError("a time is required")
|
||||
m = _TIME_RE.match(str(time_val).strip())
|
||||
if not m:
|
||||
raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
|
||||
repl["hour"] = str(int(m.group(1)))
|
||||
repl["minute"] = str(int(m.group(2)))
|
||||
|
||||
# weekday set -> dow
|
||||
if "{dow}" in sched:
|
||||
if "recurrence" in values:
|
||||
preset = str(values.get("recurrence", "everyday")).lower()
|
||||
if preset not in WEEKDAY_PRESETS:
|
||||
raise BlueprintFillError(
|
||||
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
|
||||
)
|
||||
repl["dow"] = WEEKDAY_PRESETS[preset]
|
||||
elif "day" in values:
|
||||
day = str(values.get("day", "")).lower()
|
||||
if day not in _DAY_TO_DOW:
|
||||
raise BlueprintFillError(f"unknown day {day!r}")
|
||||
repl["dow"] = _DAY_TO_DOW[day]
|
||||
else:
|
||||
repl["dow"] = "*"
|
||||
|
||||
# interval (minutes) for */N schedules
|
||||
if "{interval_min}" in sched:
|
||||
iv = str(values.get("interval_min", "")).strip()
|
||||
if not iv.isdigit() or int(iv) <= 0:
|
||||
raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
|
||||
repl["interval_min"] = iv
|
||||
|
||||
# Any remaining {slot} placeholders are filled verbatim from validated
|
||||
# enum/text slot values (e.g. an hour-range window). Enum options have
|
||||
# already been checked in fill_blueprint, so these are safe to interpolate.
|
||||
for name in re.findall(r"\{(\w+)\}", sched):
|
||||
if name not in repl and name in values:
|
||||
repl[name] = str(values[name])
|
||||
|
||||
try:
|
||||
return sched.format(**repl)
|
||||
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
|
||||
raise BlueprintFillError(f"schedule template missing value for {e}") from e
|
||||
|
||||
|
||||
def fill_blueprint(
|
||||
blueprint: AutomationBlueprint,
|
||||
values: Dict[str, Any],
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
|
||||
|
||||
Missing required (non-optional) slots raise BlueprintFillError naming the
|
||||
slot, so a form can show field errors and the agent knows what to ask.
|
||||
Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
|
||||
create a job with the default time). Enum values are checked against their
|
||||
options. The result is passed straight to ``create_job`` — no second schema.
|
||||
"""
|
||||
known = {s.name for s in blueprint.slots}
|
||||
unknown = sorted(set(values) - known)
|
||||
if unknown:
|
||||
raise BlueprintFillError(
|
||||
f"unknown slot{'s' if len(unknown) > 1 else ''}: "
|
||||
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
|
||||
)
|
||||
resolved: Dict[str, Any] = {}
|
||||
for s in blueprint.slots:
|
||||
raw = values.get(s.name, s.default)
|
||||
if raw in (None, ""):
|
||||
if s.optional:
|
||||
continue
|
||||
raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
|
||||
if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
|
||||
raise BlueprintFillError(
|
||||
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
|
||||
)
|
||||
resolved[s.name] = raw
|
||||
|
||||
schedule = _resolve_schedule(blueprint, resolved)
|
||||
|
||||
# Render the prompt with whatever slots it references.
|
||||
try:
|
||||
prompt = blueprint.prompt_template.format(**resolved)
|
||||
except KeyError as e:
|
||||
raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
|
||||
|
||||
spec: Dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"schedule": schedule,
|
||||
"name": blueprint.title,
|
||||
"deliver": resolved.get("deliver", blueprint.deliver_default),
|
||||
}
|
||||
if blueprint.skills:
|
||||
spec["skills"] = list(blueprint.skills)
|
||||
if origin is not None:
|
||||
spec["origin"] = origin
|
||||
return spec
|
||||
@@ -0,0 +1 @@
|
||||
"""Scripts shipped with the cron subsystem (runnable via ``python3 -m cron.scripts.<name>``)."""
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify candidate items by urgency/importance and emit only the urgent ones.
|
||||
|
||||
The proactive-monitor pattern: a fetch step (a watcher script, an inbox dump, a
|
||||
feed) produces a list of candidate items; this script scores each with a cheap
|
||||
LLM and prints ONLY the items at or above a threshold. Below-threshold runs
|
||||
print nothing, so a cron job wrapping this stays silent unless something
|
||||
actually matters -- the classic urgency-monitor pattern (fetch -> classify
|
||||
urgency -> surface only what's above the bar).
|
||||
|
||||
Design choices:
|
||||
* Uses Hermes' auxiliary client with task="monitor", so the classifier model
|
||||
is configured once in config.yaml (auxiliary.monitor.{provider,model}) and
|
||||
can be a cheap fast model independent of the main chat model.
|
||||
* Reads items as JSON (a list of objects) from stdin or --input-file.
|
||||
* One LLM call scores the whole batch (cheap, single round-trip) and returns
|
||||
structured scores; we filter locally.
|
||||
* Empty result -> empty stdout -> the cron job's [SILENT]/empty-stdout path
|
||||
suppresses delivery. No spam on quiet intervals.
|
||||
|
||||
Usage (standalone):
|
||||
cat items.json | python classify_items.py --threshold 7 \
|
||||
--criteria "Urgent if it needs a reply today or is from my manager/family"
|
||||
|
||||
Usage (wired to a watcher via cron, agent mode):
|
||||
Ask the agent: "Every 10 minutes, run watch_http_json.py for my inbox feed,
|
||||
pipe its JSON into classify_items.py with my urgency criteria, and deliver
|
||||
whatever it prints. Stay silent if it prints nothing."
|
||||
|
||||
Item schema (flexible): each item is an object; the classifier sees the whole
|
||||
object. A "title"/"subject"/"summary"/"text" field helps it judge. An "id"
|
||||
field (any of id/guid/message_id/url) is echoed back so duplicates can be
|
||||
deduped upstream.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _eprint(*args: Any) -> None:
|
||||
print(*args, file=sys.stderr)
|
||||
|
||||
|
||||
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
|
||||
raw = ""
|
||||
if input_file:
|
||||
with open(input_file, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
else:
|
||||
raw = sys.stdin.read()
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
_eprint(f"classify_items: input is not valid JSON: {e}")
|
||||
sys.exit(2)
|
||||
if isinstance(data, dict):
|
||||
# Allow {"items": [...]} or a single object.
|
||||
if isinstance(data.get("items"), list):
|
||||
return data["items"]
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
return [x for x in data if isinstance(x, dict)]
|
||||
_eprint("classify_items: expected a JSON list or {items: [...]}")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _item_id(item: Dict[str, Any], index: int) -> str:
|
||||
for key in ("id", "guid", "message_id", "url", "link"):
|
||||
val = item.get(key)
|
||||
if val:
|
||||
return str(val)
|
||||
return f"item-{index}"
|
||||
|
||||
|
||||
_CLASSIFY_INSTRUCTIONS = (
|
||||
"You are an urgency classifier for a proactive assistant. You will be given "
|
||||
"a numbered list of items and the user's importance criteria. Score EACH "
|
||||
"item from 0 (ignore entirely) to 10 (interrupt the user now). Return ONLY a "
|
||||
"JSON array, one object per item, in the same order: "
|
||||
'[{"index": <int>, "score": <int 0-10>, "reason": "<short>"}]. '
|
||||
"No prose, no markdown fences. Be conservative: most items should score low. "
|
||||
"Only score high when the item clearly meets the user's criteria."
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
|
||||
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
|
||||
for i, item in enumerate(items):
|
||||
# Show a compact view; the model sees the salient fields.
|
||||
view = {
|
||||
k: item[k]
|
||||
for k in ("title", "subject", "summary", "text", "body", "from", "sender", "url")
|
||||
if k in item
|
||||
}
|
||||
if not view:
|
||||
view = item # fall back to the whole object
|
||||
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
|
||||
lines.append(
|
||||
"\nReturn the JSON array of scores now (one object per item, same order)."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
|
||||
text = (content or "").strip()
|
||||
# Tolerate accidental markdown fences.
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if "\n" in text:
|
||||
text = text.split("\n", 1)[1]
|
||||
try:
|
||||
arr = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Last-ditch: find the first [...] block.
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
arr = json.loads(text[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
_eprint("classify_items: could not parse classifier output")
|
||||
return {}
|
||||
else:
|
||||
_eprint("classify_items: classifier returned no JSON array")
|
||||
return {}
|
||||
out: Dict[int, Dict[str, Any]] = {}
|
||||
if isinstance(arr, list):
|
||||
for obj in arr:
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
idx = obj.get("index")
|
||||
if isinstance(idx, int) and 0 <= idx < n_items:
|
||||
out[idx] = obj
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
|
||||
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
|
||||
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
|
||||
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
|
||||
args = parser.parse_args()
|
||||
|
||||
items = _load_items(args.input_file)
|
||||
if not items:
|
||||
# Nothing to classify -> silent. This is the common quiet-interval case.
|
||||
return 0
|
||||
|
||||
# Import here so --help works without the package importable.
|
||||
try:
|
||||
from agent.auxiliary_client import call_llm
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
_eprint(f"classify_items: cannot import auxiliary client: {e}")
|
||||
return 3
|
||||
|
||||
prompt = _build_prompt(items, args.criteria)
|
||||
try:
|
||||
resp = call_llm(
|
||||
task="monitor",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=1024,
|
||||
temperature=0,
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if not isinstance(content, str):
|
||||
content = str(content) if content else ""
|
||||
except Exception as e:
|
||||
# Classification failure is NOT silent -- surface it so a broken monitor
|
||||
# doesn't quietly swallow important items. Non-zero exit -> cron alerts.
|
||||
_eprint(f"classify_items: classifier call failed: {e}")
|
||||
return 4
|
||||
|
||||
scores = _parse_scores(content, len(items))
|
||||
surfaced = []
|
||||
for i, item in enumerate(items):
|
||||
s = scores.get(i)
|
||||
score = s.get("score") if isinstance(s, dict) else None
|
||||
if isinstance(score, int) and score >= args.threshold:
|
||||
surfaced.append((i, item, s))
|
||||
|
||||
if not surfaced:
|
||||
# Below threshold -> silent. Empty stdout; cron suppresses delivery.
|
||||
return 0
|
||||
|
||||
if args.format == "json":
|
||||
out = [
|
||||
{
|
||||
"id": _item_id(item, i),
|
||||
"score": s.get("score"),
|
||||
"reason": s.get("reason", ""),
|
||||
"item": item,
|
||||
}
|
||||
for (i, item, s) in surfaced
|
||||
]
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
blocks = []
|
||||
for (i, item, s) in surfaced:
|
||||
title = (
|
||||
item.get("title")
|
||||
or item.get("subject")
|
||||
or item.get("summary")
|
||||
or _item_id(item, i)
|
||||
)
|
||||
url = item.get("url") or item.get("link") or ""
|
||||
reason = s.get("reason", "")
|
||||
block = f"## [{s.get('score')}/10] {title}"
|
||||
if url:
|
||||
block += f"\n{url}"
|
||||
if reason:
|
||||
block += f"\n_{reason}_"
|
||||
blocks.append(block)
|
||||
print("\n\n".join(blocks))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Curated catalog of starter cron-job suggestions.
|
||||
|
||||
These are the built-in automations Hermes can offer a new user out of the box —
|
||||
the ``catalog`` source of the unified suggestion surface. Each entry is a
|
||||
ready-to-run ``cron.jobs.create_job`` spec wrapped as a suggestion; the user
|
||||
accepts via ``/suggestions``. Nothing here auto-schedules.
|
||||
|
||||
The "important-mail monitor" entry is where the old proactive-monitor engine
|
||||
lives now: its ``classify_items.py`` (poll a source -> LLM-score urgency ->
|
||||
surface only above-threshold) is ONE catalog automation, not a standalone
|
||||
feature.
|
||||
|
||||
Adding a catalog entry: append a CatalogEntry. Keep prompts self-contained
|
||||
(cron jobs run with no chat context) and schedules sensible. The ``job_spec``
|
||||
is passed verbatim to ``create_job`` on accept.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
__all__ = ["CatalogEntry", "CATALOG", "seed_catalog_suggestions", "classify_items_script_path"]
|
||||
|
||||
|
||||
def classify_items_script_path() -> str:
|
||||
"""Absolute path to the urgency classifier script shipped with cron/."""
|
||||
return str((Path(__file__).resolve().parent / "scripts" / "classify_items.py"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
"""A curated starter automation offered as a suggestion."""
|
||||
|
||||
key: str # stable dedup key (never re-offered once dismissed)
|
||||
title: str
|
||||
description: str
|
||||
job_spec: Dict[str, Any] # kwargs for cron.jobs.create_job
|
||||
|
||||
|
||||
# The curated set. Schedules use the cron/interval syntax create_job accepts.
|
||||
CATALOG: List[CatalogEntry] = [
|
||||
CatalogEntry(
|
||||
key="catalog:daily-briefing",
|
||||
title="Daily briefing",
|
||||
description="Every morning at 8am, a short briefing: today's calendar, "
|
||||
"weather, and anything urgent waiting on you.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Produce a concise morning briefing for the user: today's "
|
||||
"calendar events, the local weather, and any urgent items "
|
||||
"(unread important email, due tasks). Keep it short and "
|
||||
"scannable. If you have no connected data sources, give a brief "
|
||||
"general good-morning with the date and offer to connect "
|
||||
"calendar/email."
|
||||
),
|
||||
"schedule": "0 8 * * *",
|
||||
"name": "Daily briefing",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
CatalogEntry(
|
||||
key="catalog:important-mail-monitor",
|
||||
title="Important-mail monitor",
|
||||
description="Check your inbox periodically and ping you ONLY about mail "
|
||||
"that actually needs attention — never the newsletters.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Check the user's inbox for new messages since the last run. "
|
||||
"For each candidate, judge urgency against this rule: surface "
|
||||
"only mail that needs a reply today, is from a manager/family "
|
||||
"member, or mentions a deadline. Pipe candidates through the "
|
||||
"urgency classifier (run `python3 -m cron.scripts.classify_items "
|
||||
"--threshold 7 --criteria ...` from the hermes-agent install — "
|
||||
"resolve the script path at run time, do not assume a fixed "
|
||||
"location) and deliver ONLY what it returns. If nothing "
|
||||
"clears the bar, respond with [SILENT] so the user is not "
|
||||
"pinged. Requires a connected mail source; if none is "
|
||||
"configured, explain how to connect one and then stop."
|
||||
),
|
||||
"schedule": "every 30m",
|
||||
"name": "Important-mail monitor",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
CatalogEntry(
|
||||
key="catalog:weekly-review",
|
||||
title="Weekly review",
|
||||
description="Every Sunday evening, a recap of the week: what got done, "
|
||||
"what's still open, and what's coming up next week.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Produce a weekly review for the user: summarize what was "
|
||||
"accomplished this week, list still-open items, and preview "
|
||||
"next week's calendar. Pull from whatever sources are connected "
|
||||
"(calendar, task tools, recent conversations). Keep it tight."
|
||||
),
|
||||
"schedule": "0 18 * * 0",
|
||||
"name": "Weekly review",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
CatalogEntry(
|
||||
key="catalog:standup-reminder",
|
||||
title="Workday start reminder",
|
||||
description="A weekday nudge at 9am with your day's agenda and top "
|
||||
"priorities, so you start focused.",
|
||||
job_spec={
|
||||
"prompt": (
|
||||
"Give the user a brief weekday start-of-day nudge: their "
|
||||
"calendar for today and the 1-3 highest-priority things to "
|
||||
"focus on, inferred from recent context and any task tools. "
|
||||
"Encouraging, short, one message."
|
||||
),
|
||||
"schedule": "0 9 * * 1-5",
|
||||
"name": "Workday start reminder",
|
||||
"deliver": "origin",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def seed_catalog_suggestions(
|
||||
*,
|
||||
add_fn: Optional[Callable[..., Optional[Dict[str, Any]]]] = None,
|
||||
keys: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Register catalog entries as pending suggestions.
|
||||
|
||||
``add_fn`` defaults to ``cron.suggestions.add_suggestion`` (injectable for
|
||||
tests). ``keys`` restricts to specific catalog entries; omit to seed all.
|
||||
Entries already dismissed/accepted (by dedup key) or beyond the pending cap
|
||||
are skipped by the store, so re-seeding is safe and idempotent. Returns the
|
||||
list of suggestion records actually created.
|
||||
"""
|
||||
if add_fn is None:
|
||||
from cron.suggestions import add_suggestion as add_fn # type: ignore[assignment]
|
||||
|
||||
wanted = set(keys) if keys else None
|
||||
created: List[Dict[str, Any]] = []
|
||||
for entry in CATALOG:
|
||||
if wanted is not None and entry.key not in wanted:
|
||||
continue
|
||||
rec = add_fn(
|
||||
title=entry.title,
|
||||
description=entry.description,
|
||||
source="catalog",
|
||||
job_spec=dict(entry.job_spec),
|
||||
dedup_key=entry.key,
|
||||
)
|
||||
if rec is not None:
|
||||
created.append(rec)
|
||||
return created
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Suggested cron jobs — proposed automations the user accepts with one tap.
|
||||
|
||||
A *suggestion* is a ready-to-run cron job spec that Hermes surfaces to the
|
||||
user, who accepts it (creates the real cron job) or dismisses it (latched so
|
||||
it is never re-offered). This is the single surface every automation proposal
|
||||
flows through, regardless of where it came from:
|
||||
|
||||
* ``catalog`` — a curated starter automation (daily briefing, important-mail
|
||||
monitor, weekly digest, ...).
|
||||
* ``blueprint`` — the user installed a skill that carries a ``blueprint:`` block
|
||||
(see ``tools/blueprints.py``); installing it registers a
|
||||
suggestion instead of auto-scheduling.
|
||||
* ``usage`` — the background self-improvement review noticed a recurring
|
||||
ask that a scheduled job would serve.
|
||||
* ``integration`` — the user connected an account (Gmail, GitHub, ...) and
|
||||
the obvious automations for that surface are offered.
|
||||
|
||||
Accepting a suggestion just calls the existing ``cron.jobs.create_job`` with
|
||||
the stored ``job_spec`` — there is NO second job engine. Suggestions never
|
||||
auto-create jobs; acceptance is always explicit (consent-first). Dismissed
|
||||
suggestions latch by a stable ``dedup_key`` so the same proposal is not
|
||||
re-offered after the user says no.
|
||||
|
||||
Storage mirrors ``cron/jobs.py``: ``~/.hermes/cron/suggestions.json``, atomic
|
||||
writes, an in-process lock, and 0600 perms.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_time import now as _hermes_now
|
||||
from utils import atomic_replace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CRON_DIR = get_hermes_home().resolve() / "cron"
|
||||
SUGGESTIONS_FILE = CRON_DIR / "suggestions.json"
|
||||
|
||||
# In-process lock protecting load->modify->save cycles (the background review
|
||||
# fork and the main agent can both write).
|
||||
_suggestions_lock = threading.Lock()
|
||||
|
||||
# Cap pending suggestions so the list never becomes a nag wall. When full,
|
||||
# new suggestions are dropped (the user should clear the backlog first).
|
||||
MAX_PENDING = 5
|
||||
|
||||
VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
|
||||
_STATUS_PENDING = "pending"
|
||||
_STATUS_ACCEPTED = "accepted"
|
||||
_STATUS_DISMISSED = "dismissed"
|
||||
|
||||
|
||||
def _secure_file(path: Path) -> None:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_dir() -> None:
|
||||
CRON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _load_raw() -> Dict[str, Any]:
|
||||
if not SUGGESTIONS_FILE.exists():
|
||||
return {"suggestions": []}
|
||||
try:
|
||||
with open(SUGGESTIONS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("suggestions.json unreadable (%s); starting empty", e)
|
||||
return {"suggestions": []}
|
||||
if isinstance(data, dict) and isinstance(data.get("suggestions"), list):
|
||||
return data
|
||||
if isinstance(data, list):
|
||||
return {"suggestions": data}
|
||||
logger.warning("suggestions.json malformed; starting empty")
|
||||
return {"suggestions": []}
|
||||
|
||||
|
||||
def _save_raw(suggestions: List[Dict[str, Any]]) -> None:
|
||||
_ensure_dir()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(SUGGESTIONS_FILE.parent), suffix=".tmp", prefix=".sugg_")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"suggestions": suggestions, "updated_at": _hermes_now().isoformat()},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
atomic_replace(tmp_path, SUGGESTIONS_FILE)
|
||||
_secure_file(SUGGESTIONS_FILE)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def load_suggestions() -> List[Dict[str, Any]]:
|
||||
"""Return all suggestion records (any status)."""
|
||||
return _load_raw().get("suggestions", [])
|
||||
|
||||
|
||||
def list_pending() -> List[Dict[str, Any]]:
|
||||
"""Return pending suggestions in creation order (oldest first)."""
|
||||
return [s for s in load_suggestions() if s.get("status") == _STATUS_PENDING]
|
||||
|
||||
|
||||
def add_suggestion(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
source: str,
|
||||
job_spec: Dict[str, Any],
|
||||
dedup_key: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Register a pending suggestion. Returns the record, or None if skipped.
|
||||
|
||||
Skipped when: the source is unknown, the same ``dedup_key`` was already
|
||||
dismissed or accepted (never re-offer), an identical pending suggestion
|
||||
exists, or the pending list is full (``MAX_PENDING``).
|
||||
|
||||
``job_spec`` is a dict of kwargs for ``cron.jobs.create_job`` — accepting
|
||||
the suggestion passes it straight through, so there is no second schema to
|
||||
keep in sync.
|
||||
"""
|
||||
if source not in VALID_SOURCES:
|
||||
raise ValueError(f"unknown suggestion source: {source!r}")
|
||||
if not title.strip() or not dedup_key.strip():
|
||||
raise ValueError("title and dedup_key are required")
|
||||
|
||||
with _suggestions_lock:
|
||||
suggestions = _load_raw().get("suggestions", [])
|
||||
|
||||
# Never re-offer something the user already saw and decided on, and
|
||||
# never duplicate a still-pending proposal.
|
||||
for existing in suggestions:
|
||||
if existing.get("dedup_key") == dedup_key:
|
||||
if existing.get("status") in (_STATUS_DISMISSED, _STATUS_ACCEPTED):
|
||||
return None
|
||||
if existing.get("status") == _STATUS_PENDING:
|
||||
return None
|
||||
|
||||
pending_count = sum(1 for s in suggestions if s.get("status") == _STATUS_PENDING)
|
||||
if pending_count >= MAX_PENDING:
|
||||
logger.info("Suggestion backlog full (%d); dropping %r", MAX_PENDING, title)
|
||||
return None
|
||||
|
||||
record = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"title": title.strip(),
|
||||
"description": description.strip(),
|
||||
"source": source,
|
||||
"job_spec": job_spec,
|
||||
"dedup_key": dedup_key.strip(),
|
||||
"status": _STATUS_PENDING,
|
||||
"created_at": _hermes_now().isoformat(),
|
||||
}
|
||||
suggestions.append(record)
|
||||
_save_raw(suggestions)
|
||||
return record
|
||||
|
||||
|
||||
def get_suggestion(ref: str) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a suggestion by id, 1-based pending index, or title (exact)."""
|
||||
suggestions = load_suggestions()
|
||||
# By id.
|
||||
for s in suggestions:
|
||||
if s.get("id") == ref:
|
||||
return s
|
||||
# By 1-based pending index.
|
||||
if ref.isdigit():
|
||||
pending = [s for s in suggestions if s.get("status") == _STATUS_PENDING]
|
||||
idx = int(ref) - 1
|
||||
if 0 <= idx < len(pending):
|
||||
return pending[idx]
|
||||
# By exact title (case-insensitive).
|
||||
for s in suggestions:
|
||||
if s.get("title", "").lower() == ref.lower():
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def _set_status(suggestion_id: str, status: str) -> bool:
|
||||
with _suggestions_lock:
|
||||
suggestions = _load_raw().get("suggestions", [])
|
||||
changed = False
|
||||
for s in suggestions:
|
||||
if s.get("id") == suggestion_id:
|
||||
s["status"] = status
|
||||
s["resolved_at"] = _hermes_now().isoformat()
|
||||
changed = True
|
||||
break
|
||||
if changed:
|
||||
_save_raw(suggestions)
|
||||
return changed
|
||||
|
||||
|
||||
def dismiss_suggestion(ref: str) -> bool:
|
||||
"""Dismiss a suggestion (latched — never re-offered for its dedup_key)."""
|
||||
s = get_suggestion(ref)
|
||||
if not s:
|
||||
return False
|
||||
return _set_status(s["id"], _STATUS_DISMISSED)
|
||||
|
||||
|
||||
def accept_suggestion(ref: str, *, origin: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Accept a suggestion: create the real cron job from its ``job_spec``.
|
||||
|
||||
Returns the created cron job dict, or None if the suggestion isn't found /
|
||||
not pending. The job_spec is passed straight to ``cron.jobs.create_job``;
|
||||
an ``origin`` (platform/chat) is merged so "origin" delivery routes back to
|
||||
the chat where the user accepted.
|
||||
"""
|
||||
s = get_suggestion(ref)
|
||||
if not s or s.get("status") != _STATUS_PENDING:
|
||||
return None
|
||||
|
||||
from cron.jobs import create_job
|
||||
|
||||
spec = dict(s.get("job_spec") or {})
|
||||
if origin is not None and "origin" not in spec:
|
||||
spec["origin"] = origin
|
||||
|
||||
job = create_job(**spec)
|
||||
_set_status(s["id"], _STATUS_ACCEPTED)
|
||||
return job
|
||||
|
||||
|
||||
def clear_resolved() -> int:
|
||||
"""Drop accepted/dismissed records from disk. Returns the count removed.
|
||||
|
||||
Pending suggestions and the dedup memory of dismissed ones are the only
|
||||
things that matter long-term, but dismissed records must be RETAINED for
|
||||
their dedup_key (so they aren't re-offered). This only prunes ACCEPTED
|
||||
records, which have served their purpose once the job exists.
|
||||
"""
|
||||
with _suggestions_lock:
|
||||
suggestions = _load_raw().get("suggestions", [])
|
||||
kept = [s for s in suggestions if s.get("status") != _STATUS_ACCEPTED]
|
||||
removed = len(suggestions) - len(kept)
|
||||
if removed:
|
||||
_save_raw(kept)
|
||||
return removed
|
||||
+129
-13
@@ -1425,6 +1425,33 @@ def _build_media_placeholder(event) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _build_document_context_note(display_name: str, agent_path: str, mtype: str) -> str:
|
||||
"""Context note prepended to a user turn when they attach a document.
|
||||
|
||||
Text documents (``text/*``) have their content inlined upstream by the
|
||||
platform adapter, so the note just confirms that and records the path.
|
||||
|
||||
Binary documents (PDF, DOCX, XLSX, …) cannot be inlined as text. The note
|
||||
must tell the agent to *extract* the text itself before answering — earlier
|
||||
wording ("Ask the user what they'd like you to do with it") steered the
|
||||
model into punting back to the user, which is why attached PDFs/DOCX looked
|
||||
"unreadable" to the agent even though it has the tools to read them.
|
||||
"""
|
||||
if mtype.startswith("text/"):
|
||||
return (
|
||||
f"[The user sent a text document: '{display_name}'. "
|
||||
f"Its content has been included below. "
|
||||
f"The file is also saved at: {agent_path}]"
|
||||
)
|
||||
return (
|
||||
f"[The user sent a document: '{display_name}'. It is saved at: {agent_path}. "
|
||||
f"Its text is not inlined here (it's a binary format such as PDF or DOCX). "
|
||||
f"To read it, extract the document's text yourself — for example with the "
|
||||
f"terminal tool or the ocr-and-documents skill — before answering, instead "
|
||||
f"of asking the user to paste the contents.]"
|
||||
)
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
total = int(round(seconds))
|
||||
if total < 0:
|
||||
@@ -7171,6 +7198,37 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "kanban":
|
||||
return await self._handle_kanban_command(event)
|
||||
|
||||
if canonical == "suggestions":
|
||||
return await self._handle_suggestions_command(event)
|
||||
|
||||
if canonical == "blueprint":
|
||||
_blueprint_result = await self._handle_blueprint_command(event)
|
||||
_blueprint_seed = getattr(_blueprint_result, "agent_seed", None)
|
||||
if _blueprint_seed:
|
||||
# Blueprint matched — rewrite the turn to the seed and fall
|
||||
# through to _handle_message_with_agent so the agent asks the
|
||||
# user for each slot value conversationally and then calls the
|
||||
# cronjob tool (the /steer fall-through pattern). The seed
|
||||
# enters as a normal user turn, preserving role alternation.
|
||||
# Send the "Setting up X…" ack first so the user gets the same
|
||||
# immediate feedback CLI users see, instead of silence until
|
||||
# the agent's first question.
|
||||
_ack = getattr(_blueprint_result, "text", "") or ""
|
||||
if _ack:
|
||||
try:
|
||||
adapter = self.adapters.get(source.platform)
|
||||
if adapter:
|
||||
_ack_meta = self._thread_metadata_for_source(source)
|
||||
await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta)
|
||||
except Exception:
|
||||
logger.debug("blueprint ack send failed", exc_info=True)
|
||||
try:
|
||||
event.text = _blueprint_seed
|
||||
except Exception:
|
||||
return getattr(_blueprint_result, "text", "") or None
|
||||
else:
|
||||
return getattr(_blueprint_result, "text", "") or None
|
||||
|
||||
if canonical == "retry":
|
||||
return await self._handle_retry_command(event)
|
||||
|
||||
@@ -7669,7 +7727,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_note = (
|
||||
f"[The user sent an audio file attachment: '{_display}'. "
|
||||
f"It is saved at: {_agent_path}. "
|
||||
f"Ask the user what they'd like you to do with it, or pass the path to a transcription or media tool.]"
|
||||
f"Its content is not inlined here. If the user's request involves "
|
||||
f"what the audio contains, transcribe or process it yourself — for "
|
||||
f"example by passing the path to a transcription or media tool — "
|
||||
f"instead of asking the user to describe it. Only ask what to do "
|
||||
f"with it if their intent is genuinely unclear.]"
|
||||
)
|
||||
message_text = f"{_note}\n\n{message_text}"
|
||||
|
||||
@@ -7701,18 +7763,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# cache directories are auto-mounted at /root/.hermes/cache/* by get_cache_directory_mounts().
|
||||
agent_path = to_agent_visible_cache_path(path)
|
||||
|
||||
if mtype.startswith("text/"):
|
||||
context_note = (
|
||||
f"[The user sent a text document: '{display_name}'. "
|
||||
f"Its content has been included below. "
|
||||
f"The file is also saved at: {agent_path}]"
|
||||
)
|
||||
else:
|
||||
context_note = (
|
||||
f"[The user sent a document: '{display_name}'. "
|
||||
f"The file is saved at: {agent_path}. "
|
||||
f"Ask the user what they'd like you to do with it.]"
|
||||
)
|
||||
context_note = _build_document_context_note(display_name, agent_path, mtype)
|
||||
message_text = f"{context_note}\n\n{message_text}"
|
||||
|
||||
if getattr(event, "reply_to_text", None) and event.reply_to_message_id:
|
||||
@@ -9237,6 +9288,71 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
|
||||
|
||||
async def _handle_suggestions_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /suggestions in the gateway.
|
||||
|
||||
Delegates to the shared handler so CLI and gateway never drift. The
|
||||
origin is built from the event source so an accepted suggestion's job
|
||||
delivers back to this chat/thread.
|
||||
"""
|
||||
args = (event.get_command_args() or "").strip()
|
||||
source = event.source
|
||||
origin = None
|
||||
try:
|
||||
platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "")
|
||||
chat_id = getattr(source, "chat_id", None)
|
||||
if platform and chat_id:
|
||||
origin = {
|
||||
"platform": platform,
|
||||
"chat_id": str(chat_id),
|
||||
"chat_name": getattr(source, "chat_name", None),
|
||||
"thread_id": getattr(source, "thread_id", None),
|
||||
}
|
||||
except Exception:
|
||||
origin = None
|
||||
try:
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
|
||||
return handle_suggestions_command(args, origin=origin, surface="gateway")
|
||||
except Exception as e:
|
||||
logger.debug("suggestions command failed: %s", e)
|
||||
return f"Suggestions command failed: {e}"
|
||||
|
||||
async def _handle_blueprint_command(self, event: MessageEvent):
|
||||
"""Handle /blueprint in the gateway.
|
||||
|
||||
Delegates to the shared handler so CLI, TUI, and gateway never drift.
|
||||
Returns a BlueprintCommandResult: ``text`` is shown to the user, and if
|
||||
``agent_seed`` is set the dispatch site rewrites ``event.text`` to the
|
||||
seed and falls through to the agent (the ``/steer`` pattern) so the
|
||||
agent gathers the slot values conversationally. Origin is built from the
|
||||
event source so a directly created blueprint job delivers back to this chat.
|
||||
"""
|
||||
args = (event.get_command_args() or "").strip()
|
||||
source = event.source
|
||||
origin = None
|
||||
try:
|
||||
platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "")
|
||||
chat_id = getattr(source, "chat_id", None)
|
||||
if platform and chat_id:
|
||||
origin = {
|
||||
"platform": platform,
|
||||
"chat_id": str(chat_id),
|
||||
"chat_name": getattr(source, "chat_name", None),
|
||||
"thread_id": getattr(source, "thread_id", None),
|
||||
}
|
||||
except Exception:
|
||||
origin = None
|
||||
try:
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
|
||||
return handle_blueprint_command(args, origin=origin, surface="gateway")
|
||||
except Exception as e:
|
||||
logger.debug("blueprint command failed: %s", e)
|
||||
from hermes_cli.blueprint_cmd import BlueprintCommandResult
|
||||
|
||||
return BlueprintCommandResult(f"Cron blueprint command failed: {e}")
|
||||
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
# /goal — persistent cross-turn goals (Ralph-style loop)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -149,7 +149,7 @@ hermes webhook subscribe pr-review \
|
||||
--deliver github_comment
|
||||
```
|
||||
|
||||
Full automation templates gallery: [hermes-agent.nousresearch.com/docs/guides/automation-templates](https://hermes-agent.nousresearch.com/docs/guides/automation-templates)
|
||||
Full automation blueprints gallery: [hermes-agent.nousresearch.com/docs/reference/automation-blueprints-catalog](https://hermes-agent.nousresearch.com/docs/reference/automation-blueprints-catalog)
|
||||
|
||||
Documentation: [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com)
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Shared ``/blueprint`` command logic for CLI, TUI, and gateway.
|
||||
|
||||
The conversational counterpart to the dashboard's Automation Blueprints form. Where a
|
||||
surface has a screen, the user fills a form (dashboard / GUI app) and the API
|
||||
calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a
|
||||
chat line, the user picks a blueprint by name and the agent asks for what it
|
||||
needs — pick a blueprint by name and the agent asks you for what it needs, one
|
||||
question at a time (the messaging-assistant model: pick a blueprint → it asks you
|
||||
a couple things → done).
|
||||
|
||||
Subcommand shapes:
|
||||
/blueprint list the catalog
|
||||
/blueprint <name> name-match a blueprint, then SEED THE AGENT to
|
||||
ask the user for each value conversationally
|
||||
/blueprint <name> slot=val … fill + create the cron job directly
|
||||
(the deterministic dashboard / docs / power-
|
||||
user shortcut — no agent turn)
|
||||
|
||||
The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all
|
||||
resolve; an ambiguous query lists the candidates; an unknown one suggests the
|
||||
closest. When it resolves, the handler returns an ``agent_seed`` — a natural-
|
||||
language instruction built from the blueprint's typed slots + schedule/prompt
|
||||
templates — that the calling surface feeds to the agent as a normal user turn
|
||||
(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI:
|
||||
a one-shot pending seed the main loop runs). The agent then asks for each slot
|
||||
and calls the existing ``cronjob`` tool. No new tool, no second job engine.
|
||||
|
||||
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
|
||||
survive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import logging
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlueprintCommandResult:
|
||||
"""Outcome of a ``/blueprint`` invocation.
|
||||
|
||||
``text`` is always shown to the user. When ``agent_seed`` is set, the
|
||||
calling surface should ALSO hand that seed to the agent as the user's next
|
||||
turn (the blueprint was matched and now the agent gathers the slot values
|
||||
conversationally). When ``agent_seed`` is None the command is fully handled
|
||||
(catalog listing, direct create, or an error) and nothing is sent to the
|
||||
agent.
|
||||
"""
|
||||
|
||||
text: str
|
||||
agent_seed: Optional[str] = None
|
||||
|
||||
|
||||
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
platform = get_session_env("HERMES_SESSION_PLATFORM")
|
||||
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
|
||||
if platform and chat_id:
|
||||
return {
|
||||
"platform": platform,
|
||||
"chat_id": chat_id,
|
||||
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
|
||||
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
|
||||
"""Split ``slot=value`` tokens from bare tokens. Returns (values, leftovers)."""
|
||||
values: Dict[str, str] = {}
|
||||
leftovers = []
|
||||
for tok in tokens:
|
||||
if "=" in tok:
|
||||
k, _, v = tok.partition("=")
|
||||
k = k.strip()
|
||||
if k:
|
||||
values[k] = v.strip()
|
||||
continue
|
||||
leftovers.append(tok)
|
||||
return values, leftovers
|
||||
|
||||
|
||||
def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
|
||||
"""Resolve a free-typed blueprint name to a blueprint.
|
||||
|
||||
Returns ``(blueprint, candidates)``:
|
||||
* exact key or unique prefix / fuzzy match -> ``(blueprint, [])``
|
||||
* ambiguous (2+ plausible) -> ``(None, [candidates…])``
|
||||
* no plausible match -> ``(None, [])``
|
||||
|
||||
Matching is forgiving because chat-line users type the name (unlike the
|
||||
dashboard/Discord where it's picked): exact key first, then case-insensitive
|
||||
prefix on key or title, then a difflib fuzzy pass.
|
||||
"""
|
||||
from cron.blueprint_catalog import CATALOG, get_blueprint
|
||||
|
||||
q = (query or "").strip().lower()
|
||||
if not q:
|
||||
return None, []
|
||||
|
||||
exact = get_blueprint(q)
|
||||
if exact is not None:
|
||||
return exact, []
|
||||
|
||||
# Prefix match on key or title word-start.
|
||||
prefix = [
|
||||
r for r in CATALOG
|
||||
if r.key.lower().startswith(q)
|
||||
or any(w.lower().startswith(q) for w in r.title.split())
|
||||
]
|
||||
if len(prefix) == 1:
|
||||
return prefix[0], []
|
||||
if len(prefix) > 1:
|
||||
return None, prefix
|
||||
|
||||
# Substring match anywhere in key/title/description.
|
||||
substr = [
|
||||
r for r in CATALOG
|
||||
if q in r.key.lower() or q in r.title.lower() or q in r.description.lower()
|
||||
]
|
||||
if len(substr) == 1:
|
||||
return substr[0], []
|
||||
if len(substr) > 1:
|
||||
return None, substr
|
||||
|
||||
# Fuzzy on keys (typo tolerance).
|
||||
keys = [r.key for r in CATALOG]
|
||||
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
|
||||
if len(close) == 1:
|
||||
return get_blueprint(close[0]), []
|
||||
if len(close) > 1:
|
||||
return None, [get_blueprint(k) for k in close]
|
||||
|
||||
return None, []
|
||||
|
||||
|
||||
def _humanize_schedule(blueprint) -> str:
|
||||
from cron.blueprint_catalog import _humanize_schedule as _h
|
||||
|
||||
try:
|
||||
return _h(blueprint)
|
||||
except Exception:
|
||||
return "on a schedule"
|
||||
|
||||
|
||||
def build_blueprint_seed(blueprint) -> str:
|
||||
"""Build the natural-language fill-request the agent will act on.
|
||||
|
||||
The agent reads this as a normal user turn, asks the user for each unfilled
|
||||
slot one at a time, then calls the ``cronjob`` tool with the
|
||||
cron expression it builds from the blueprint's ``schedule_template`` and the
|
||||
rendered prompt. Defaults are stated so the agent can offer them.
|
||||
"""
|
||||
from cron.blueprint_catalog import WEEKDAY_PRESETS
|
||||
|
||||
lines: List[str] = []
|
||||
lines.append(
|
||||
f"Set up the '{blueprint.title}' automation for me (automation blueprint "
|
||||
f"'{blueprint.key}'). {blueprint.description}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Ask me for each of these, one at a time, offering the default in "
|
||||
"brackets if I don't have a preference:"
|
||||
)
|
||||
for s in blueprint.slots:
|
||||
bits = [f"- {s.label} ({s.name})"]
|
||||
if s.options:
|
||||
bits.append(f" — one of: {', '.join(map(str, s.options))}")
|
||||
if s.default not in (None, ""):
|
||||
bits.append(f" [default: {s.default}]")
|
||||
if s.optional:
|
||||
bits.append(" (optional)")
|
||||
if s.help:
|
||||
bits.append(f" — {s.help}")
|
||||
lines.append("".join(bits))
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Once you have my answers, create the job by calling the cronjob tool "
|
||||
"with action='create'. Build the schedule as a cron expression from "
|
||||
f"this template: `{blueprint.schedule_template}` "
|
||||
"(fill {minute}/{hour} from the chosen time, {dow} from the weekday "
|
||||
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
|
||||
"interval). Use this exact prompt for the job (substituting my "
|
||||
f"answers into any {{slot}} placeholders): \"{blueprint.prompt_template}\". "
|
||||
"Confirm the schedule and what it will do before you create it."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_catalog() -> str:
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
lines = ["Automation Blueprints — `/blueprint <name>` and I'll ask you what I need:\n"]
|
||||
for r in CATALOG:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append(f" {r.description}")
|
||||
lines.append(
|
||||
"\nTip: `/blueprint <name>` walks you through it. Power users can "
|
||||
"pass values inline, e.g. `/blueprint morning-brief time=08:00`."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_candidates(query: str, candidates: List[Any]) -> str:
|
||||
lines = [f"'{query}' matches several blueprints — which one?\n"]
|
||||
for r in candidates:
|
||||
lines.append(f" • {r.key} — {r.title}")
|
||||
lines.append("\nRun `/blueprint <name>` with one of the names above.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _fmt_no_match(query: str) -> str:
|
||||
from cron.blueprint_catalog import CATALOG
|
||||
|
||||
keys = [r.key for r in CATALOG]
|
||||
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
|
||||
msg = f"No automation blueprint matches '{query}'."
|
||||
if close:
|
||||
msg += " Did you mean: " + ", ".join(close) + "?"
|
||||
msg += " Run /blueprint to see the catalog."
|
||||
return msg
|
||||
|
||||
|
||||
def _manage_hint(surface: str) -> str:
|
||||
"""Post-create management hint. /cron is a CLI-only slash command; on
|
||||
gateway platforms the user manages jobs by asking the agent (cronjob tool)
|
||||
or from the dashboard."""
|
||||
if surface == "cli":
|
||||
return "Manage it with /cron."
|
||||
return "Ask me to list, pause, or remove it any time."
|
||||
|
||||
|
||||
def handle_blueprint_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
surface: str = "cli",
|
||||
) -> BlueprintCommandResult:
|
||||
"""Dispatch a ``/blueprint`` invocation.
|
||||
|
||||
Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the
|
||||
caller must feed it to the agent as the next user turn; otherwise the
|
||||
command is fully handled and only ``text`` is shown.
|
||||
|
||||
``args`` is everything after ``/blueprint``. ``origin`` lets a directly
|
||||
created job deliver back to the chat it was set up from. ``surface``
|
||||
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints —
|
||||
``/cron`` only exists on the CLI.
|
||||
"""
|
||||
try:
|
||||
from cron.blueprint_catalog import fill_blueprint, BlueprintFillError
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
logger.debug("blueprint catalog import failed: %s", e)
|
||||
return BlueprintCommandResult("Automation Blueprints are unavailable in this build.")
|
||||
|
||||
try:
|
||||
tokens = shlex.split(args or "")
|
||||
except ValueError:
|
||||
tokens = (args or "").split()
|
||||
|
||||
# Bare -> list catalog.
|
||||
if not tokens:
|
||||
return BlueprintCommandResult(_fmt_catalog())
|
||||
|
||||
query = tokens[0]
|
||||
values, _leftover = _parse_kv(tokens[1:])
|
||||
|
||||
blueprint, candidates = match_blueprint(query)
|
||||
if blueprint is None:
|
||||
if candidates:
|
||||
return BlueprintCommandResult(_fmt_candidates(query, candidates))
|
||||
return BlueprintCommandResult(_fmt_no_match(query))
|
||||
|
||||
# `<name>` with no inline slot values -> seed the agent to ask for them.
|
||||
if not values:
|
||||
seed = build_blueprint_seed(blueprint)
|
||||
text = (
|
||||
f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). "
|
||||
"I'll ask you a couple of things…"
|
||||
)
|
||||
return BlueprintCommandResult(text, agent_seed=seed)
|
||||
|
||||
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
|
||||
try:
|
||||
spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin))
|
||||
except BlueprintFillError as e:
|
||||
return BlueprintCommandResult(
|
||||
f"Can't set up '{blueprint.title}': {e}\n"
|
||||
f"Or just run /blueprint {blueprint.key} and I'll ask you for the values."
|
||||
)
|
||||
|
||||
try:
|
||||
from cron.jobs import create_job
|
||||
|
||||
job = create_job(**spec)
|
||||
except Exception as e:
|
||||
logger.debug("blueprint create_job failed: %s", e)
|
||||
return BlueprintCommandResult(f"Failed to create the job: {e}")
|
||||
|
||||
sched = job.get("schedule_display") or spec.get("schedule", "")
|
||||
return BlueprintCommandResult(
|
||||
f"Scheduled '{blueprint.title}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
|
||||
)
|
||||
@@ -1255,6 +1255,57 @@ class CLICommandsMixin:
|
||||
print(f"(._.) Unknown cron command: {subcommand}")
|
||||
print(" Available: list, add, edit, pause, resume, run, remove")
|
||||
|
||||
def _handle_suggestions_command(self, cmd: str):
|
||||
"""Handle /suggestions — review/accept/dismiss suggested automations.
|
||||
|
||||
Delegates to the shared handler so CLI and gateway never drift. CLI
|
||||
origin is the local platform so an accepted job's "origin" delivery
|
||||
resolves to a configured home channel.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
try:
|
||||
tokens = shlex.split(cmd)[1:] if cmd else []
|
||||
except ValueError:
|
||||
tokens = (cmd or "").split()[1:]
|
||||
args = " ".join(tokens)
|
||||
try:
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
output = handle_suggestions_command(args)
|
||||
except Exception as e:
|
||||
output = f"Suggestions command failed: {e}"
|
||||
self._console_print(output)
|
||||
|
||||
def _handle_blueprint_command(self, cmd: str):
|
||||
"""Handle /blueprint — set up an automation from a blueprint template.
|
||||
|
||||
Delegates to the shared handler. A bare ``/blueprint`` lists the
|
||||
catalog; ``/blueprint <name>`` name-matches a blueprint and seeds the
|
||||
agent to ask the user for each value conversationally (the result's
|
||||
``agent_seed``); ``/blueprint <name> slot=val …`` creates the job
|
||||
directly. When a seed is returned it is stashed as a one-shot pending
|
||||
message the interactive loop runs as the next agent turn.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
try:
|
||||
tokens = shlex.split(cmd)[1:] if cmd else []
|
||||
except ValueError:
|
||||
tokens = (cmd or "").split()[1:]
|
||||
args = " ".join(shlex.quote(t) for t in tokens)
|
||||
try:
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
result = handle_blueprint_command(args)
|
||||
except Exception as e:
|
||||
self._console_print(f"Cron blueprint command failed: {e}")
|
||||
return
|
||||
self._console_print(result.text)
|
||||
seed = getattr(result, "agent_seed", None)
|
||||
if seed:
|
||||
# One-shot: the interactive loop picks this up right after the
|
||||
# slash command returns and runs it as a normal agent turn.
|
||||
self._pending_agent_seed = seed
|
||||
|
||||
def _handle_curator_command(self, cmd: str):
|
||||
"""Handle /curator slash command.
|
||||
|
||||
|
||||
@@ -179,6 +179,11 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
|
||||
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
|
||||
subcommands=("accept", "dismiss", "catalog", "clear")),
|
||||
CommandDef("blueprint", "Set up an automation from a blueprint template",
|
||||
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
|
||||
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
|
||||
"Tools & Skills", args_hint="[subcommand]",
|
||||
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),
|
||||
@@ -1025,6 +1030,19 @@ _SLACK_RESERVED_COMMANDS = frozenset({
|
||||
"topic", "mute", "pro", "shortcuts",
|
||||
})
|
||||
|
||||
# High-value aliases that must survive Slack's 50-slash cap even when the
|
||||
# registry fills up. Without this, adding a new canonical command silently
|
||||
# clamps off low-priority aliases (they're added in the second pass), so a
|
||||
# long-standing native slash like /btw could disappear just because an
|
||||
# unrelated command landed. These claim their slots right after /hermes,
|
||||
# ahead of both canonical names and the rest of the aliases. Anything not
|
||||
# listed here still degrades gracefully (reachable via /hermes <command>).
|
||||
# Keep this list TIGHT: every pinned alias takes a slot a canonical command
|
||||
# would otherwise get, and the Telegram-parity test fails when a canonical
|
||||
# gets clamped ("reset" was unpinned for exactly that — /new keeps its
|
||||
# native slot, the alias spelling stays reachable via /hermes reset).
|
||||
_SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
"""Convert a command name to a valid Slack slash command name.
|
||||
@@ -1079,6 +1097,21 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
|
||||
entries.append((slack_name, desc[:140], hint[:100]))
|
||||
seen.add(slack_name)
|
||||
|
||||
# Priority pass: pin high-value aliases (e.g. /btw, /bg, /reset) ahead of
|
||||
# everything except /hermes, so a new canonical command can never silently
|
||||
# clamp them off the 50-slash cap. Each alias borrows its parent command's
|
||||
# description and hint.
|
||||
_alias_to_cmd = {
|
||||
alias: cmd
|
||||
for cmd in COMMAND_REGISTRY
|
||||
if _is_gateway_available(cmd, overrides)
|
||||
for alias in cmd.aliases
|
||||
}
|
||||
for alias in _SLACK_PRIORITY_ALIASES:
|
||||
cmd = _alias_to_cmd.get(alias)
|
||||
if cmd is not None:
|
||||
_add(alias, f"Alias for /{cmd.name} — {cmd.description}", cmd.args_hint or "")
|
||||
|
||||
# First pass: canonical names (so they win slots if we hit the cap).
|
||||
for cmd in COMMAND_REGISTRY:
|
||||
if not _is_gateway_available(cmd, overrides):
|
||||
|
||||
+17
-1
@@ -877,7 +877,9 @@ DEFAULT_CONFIG = {
|
||||
# Toolsets are never touched; messaging platforms
|
||||
# unaffected.
|
||||
# "focus" — auto + collapse the toolset to the lean coding
|
||||
# set (+ enabled MCP servers). Explicit opt-in.
|
||||
# set (+ enabled MCP servers) + demote non-coding
|
||||
# skill categories to names-only in the prompt's
|
||||
# skill index. Explicit opt-in.
|
||||
# "on" — force the prompt posture everywhere.
|
||||
# "off" — disable entirely.
|
||||
"coding_context": "auto",
|
||||
@@ -1367,6 +1369,20 @@ DEFAULT_CONFIG = {
|
||||
"timeout": 600,
|
||||
"extra_body": {},
|
||||
},
|
||||
# Monitor — urgency/importance classifier used by the important-mail
|
||||
# monitor catalog automation (cron/scripts/classify_items.py). Scores
|
||||
# candidate items 0-10 against the user's criteria so only above-
|
||||
# threshold items get delivered. "auto" = main chat model; override to
|
||||
# a cheap fast model (e.g. openrouter google/gemini-3-flash-preview,
|
||||
# haiku) since per-item scoring is high-volume and a small model is fine.
|
||||
"monitor": {
|
||||
"provider": "auto",
|
||||
"model": "",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"timeout": 60,
|
||||
"extra_body": {},
|
||||
},
|
||||
},
|
||||
|
||||
"display": {
|
||||
|
||||
+29
-2
@@ -4609,13 +4609,18 @@ def _run_npm_install_deterministic(
|
||||
the working tree dirty and causes the next ``hermes update`` to stash the
|
||||
lockfile — repeatedly.
|
||||
"""
|
||||
# unicode-animations' postinstall animates to /dev/tty (bypasses
|
||||
# --silent/capture_output). It no-ops when CI is set — same as the TUI
|
||||
# install path and nix/lib.nix npm ci hooks.
|
||||
run_env = {**os.environ, **(env or {}), "CI": "1"}
|
||||
|
||||
lockfile = cwd / "package-lock.json"
|
||||
if lockfile.exists():
|
||||
ci_cmd = [npm, "ci", *extra_args]
|
||||
ci_result = subprocess.run(
|
||||
ci_cmd,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
env=run_env,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -4630,7 +4635,7 @@ def _run_npm_install_deterministic(
|
||||
return subprocess.run(
|
||||
install_cmd,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
env=run_env,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -10327,6 +10332,8 @@ def cmd_dashboard(args):
|
||||
_launch_profile not in ("default", "custom")
|
||||
and not getattr(args, "isolated", False)
|
||||
and not getattr(args, "open_profile", "")
|
||||
# Desktop pool backends are intentionally per-profile.
|
||||
and os.environ.get("HERMES_DESKTOP") != "1"
|
||||
):
|
||||
url = f"http://{args.host or '127.0.0.1'}:{args.port}/?profile={_launch_profile}"
|
||||
if _dashboard_listening(args.host, args.port):
|
||||
@@ -10426,6 +10433,26 @@ def cmd_dashboard(args):
|
||||
# the missing-provider state if it matters.
|
||||
print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr)
|
||||
|
||||
# Desktop chat uses the dashboard's in-process /api/ws gateway, which builds
|
||||
# agents via tui_gateway.server._make_agent. That path only snapshots the
|
||||
# tool registry — it never starts MCP discovery (the stdio TUI does that in
|
||||
# tui_gateway/entry.py, which the dashboard process doesn't run). Without
|
||||
# this, a profile's configured MCP servers never connect, so desktop
|
||||
# sessions show no MCP tools. Spawn discovery in the background here so a
|
||||
# slow/dead server can't block dashboard startup.
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="dashboard-mcp-discovery",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Background MCP tool discovery failed at dashboard startup",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
from hermes_cli.web_server import start_server
|
||||
|
||||
# The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always
|
||||
|
||||
@@ -1069,8 +1069,21 @@ class PluginManager:
|
||||
self._plugin_skills.clear()
|
||||
self._aux_tasks.clear()
|
||||
self._context_engine = None
|
||||
# Set the flag up front as a re-entrancy guard (a plugin's register()
|
||||
# can transitively trigger discovery again), but reset it if the sweep
|
||||
# raises so a failed scan is NOT cached as "discovered with an empty
|
||||
# registry" — callers swallow the exception and would otherwise be
|
||||
# permanently stranded on the early-return above (the "No web provider
|
||||
# configured" class of failures).
|
||||
self._discovered = True
|
||||
try:
|
||||
self._discover_and_load_inner()
|
||||
except BaseException:
|
||||
self._discovered = False
|
||||
raise
|
||||
|
||||
def _discover_and_load_inner(self) -> None:
|
||||
"""The actual discovery sweep — see :meth:`discover_and_load`."""
|
||||
manifests: List[PluginManifest] = []
|
||||
|
||||
# 1. Bundled plugins (<repo>/plugins/<name>/)
|
||||
|
||||
@@ -691,6 +691,47 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
||||
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
||||
|
||||
# Blueprint detection: if the installed skill declares a
|
||||
# metadata.hermes.blueprint block, it is a runnable automation. Register it as
|
||||
# a Suggested Cron Job rather than auto-scheduling — installing never
|
||||
# silently creates a recurring job; the user accepts it via /suggestions.
|
||||
# This is the single surface every automation proposal flows through.
|
||||
try:
|
||||
from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion
|
||||
|
||||
try:
|
||||
spec = blueprint_spec_for_installed(bundle.name)
|
||||
except BlueprintError as _rec_err:
|
||||
c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n")
|
||||
spec = None
|
||||
if spec is not None:
|
||||
registered = register_blueprint_suggestion(spec)
|
||||
if registered is not None:
|
||||
c.print(
|
||||
f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation "
|
||||
f"(schedule [bold]{spec.schedule}[/])."
|
||||
)
|
||||
c.print(
|
||||
"[dim]Added to your suggestions — run[/] [bold]/suggestions[/] "
|
||||
"[dim]to schedule or dismiss it.[/]\n"
|
||||
)
|
||||
else:
|
||||
# Dropped: already offered/dismissed (latched) or the pending
|
||||
# list is at its cap. Say so instead of silently doing nothing —
|
||||
# the user can still schedule it by hand.
|
||||
c.print(
|
||||
f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation "
|
||||
f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to "
|
||||
"your suggestions (already offered/dismissed, or the pending "
|
||||
"list is full — run [bold]/suggestions[/] to review)."
|
||||
)
|
||||
c.print(
|
||||
"[dim]You can still schedule it any time by asking the agent "
|
||||
"or via[/] [bold]hermes cron add[/][dim].[/]\n"
|
||||
)
|
||||
except Exception: # pragma: no cover - blueprint detection is best-effort
|
||||
pass
|
||||
|
||||
if invalidate_cache:
|
||||
# Invalidate the skills prompt cache so the new skill appears immediately
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Shared ``/suggestions`` command logic for CLI and gateway.
|
||||
|
||||
Both surfaces call ``handle_suggestions_command(args, origin=...)`` and present
|
||||
the returned text however they present command output. Keeping the logic here
|
||||
(not in cli.py / gateway/run.py) means the two surfaces can never drift.
|
||||
|
||||
Subcommands:
|
||||
/suggestions list pending suggestions (numbered)
|
||||
/suggestions accept <N|id> create the cron job for that suggestion
|
||||
/suggestions dismiss <N|id> dismiss it (latched, never re-offered)
|
||||
/suggestions catalog seed the curated starter automations as pending
|
||||
/suggestions clear drop accepted records (housekeeping)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fmt_pending(pending: list) -> str:
|
||||
if not pending:
|
||||
return (
|
||||
"No suggested automations right now.\n"
|
||||
"Try `/suggestions catalog` to see the curated starter set, or "
|
||||
"install a blueprint skill to get one."
|
||||
)
|
||||
lines = ["Suggested automations — `/suggestions accept N` or `dismiss N`:\n"]
|
||||
for i, s in enumerate(pending, 1):
|
||||
spec = s.get("job_spec", {}) or {}
|
||||
sched = spec.get("schedule", "?")
|
||||
src = s.get("source", "?")
|
||||
lines.append(f" {i}. {s.get('title', '(untitled)')} [{sched}] ({src})")
|
||||
desc = s.get("description", "").strip()
|
||||
if desc:
|
||||
lines.append(f" {desc}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_origin() -> Optional[Dict[str, Any]]:
|
||||
"""Best-effort current-chat origin from session env (CLI and gateway both set it).
|
||||
|
||||
Mirrors cron's ``_origin_from_env`` so an accepted suggestion's job delivers
|
||||
back to the chat where it was accepted. Returns None if unavailable, in
|
||||
which case create_job falls back to a configured home channel.
|
||||
"""
|
||||
try:
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
platform = get_session_env("HERMES_SESSION_PLATFORM")
|
||||
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
|
||||
if platform and chat_id:
|
||||
return {
|
||||
"platform": platform,
|
||||
"chat_id": chat_id,
|
||||
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
|
||||
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def handle_suggestions_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
surface: str = "cli",
|
||||
) -> str:
|
||||
"""Dispatch a ``/suggestions`` invocation. Returns text to show the user.
|
||||
|
||||
``args`` is everything after ``/suggestions`` (already stripped of the
|
||||
command word). ``origin`` is the platform/chat dict so an accepted job's
|
||||
"origin" delivery routes back to where the user accepted; when omitted it
|
||||
is resolved from the session environment. ``surface`` (``"cli"`` |
|
||||
``"gateway"``) picks the wording for follow-up hints — ``/cron`` only
|
||||
exists on the CLI.
|
||||
"""
|
||||
if origin is None:
|
||||
origin = _resolve_origin()
|
||||
try:
|
||||
from cron import suggestions as store
|
||||
except Exception as e: # pragma: no cover - import guard
|
||||
logger.debug("suggestions store import failed: %s", e)
|
||||
return "Suggestions are unavailable in this build."
|
||||
|
||||
parts = (args or "").strip().split()
|
||||
sub = parts[0].lower() if parts else ""
|
||||
rest = " ".join(parts[1:]).strip()
|
||||
|
||||
# Bare /suggestions -> list pending.
|
||||
if not sub:
|
||||
return _fmt_pending(store.list_pending())
|
||||
|
||||
if sub in ("accept", "add", "schedule"):
|
||||
if not rest:
|
||||
return "Usage: /suggestions accept <number|id>"
|
||||
job = store.accept_suggestion(rest, origin=origin)
|
||||
if job is None:
|
||||
return f"No pending suggestion matches '{rest}'. Run /suggestions to list them."
|
||||
sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "")
|
||||
name = job.get("name", "automation")
|
||||
manage = (
|
||||
"Manage it with /cron."
|
||||
if surface == "cli"
|
||||
else "Ask me to list, pause, or remove it any time."
|
||||
)
|
||||
return (
|
||||
f"Scheduled '{name}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f". {manage}"
|
||||
)
|
||||
|
||||
if sub in ("dismiss", "no", "reject"):
|
||||
if not rest:
|
||||
return "Usage: /suggestions dismiss <number|id>"
|
||||
ok = store.dismiss_suggestion(rest)
|
||||
return (
|
||||
f"Dismissed. Won't suggest that again."
|
||||
if ok
|
||||
else f"No pending suggestion matches '{rest}'."
|
||||
)
|
||||
|
||||
if sub == "catalog":
|
||||
try:
|
||||
from cron.suggestion_catalog import seed_catalog_suggestions
|
||||
|
||||
created = seed_catalog_suggestions()
|
||||
except Exception as e:
|
||||
logger.debug("catalog seed failed: %s", e)
|
||||
return "Couldn't load the catalog."
|
||||
if not created:
|
||||
return (
|
||||
"No new catalog automations to add (already offered, dismissed, "
|
||||
"or your suggestion list is full). Run /suggestions to see pending."
|
||||
)
|
||||
added = ", ".join(c.get("title", "?") for c in created)
|
||||
return f"Added {len(created)} suggestion(s): {added}.\nRun /suggestions to review."
|
||||
|
||||
if sub == "clear":
|
||||
removed = store.clear_resolved()
|
||||
return f"Cleared {removed} resolved suggestion record(s)."
|
||||
|
||||
return (
|
||||
"Usage:\n"
|
||||
" /suggestions list pending\n"
|
||||
" /suggestions accept N schedule suggestion N\n"
|
||||
" /suggestions dismiss N dismiss suggestion N\n"
|
||||
" /suggestions catalog add curated starter automations\n"
|
||||
" /suggestions clear housekeeping"
|
||||
)
|
||||
+331
-2
@@ -920,6 +920,178 @@ class ManagedFilesPolicy:
|
||||
can_change_path: bool
|
||||
|
||||
|
||||
_FS_READDIR_HIDDEN = {
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".cache",
|
||||
".next",
|
||||
".turbo",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"target",
|
||||
"venv",
|
||||
}
|
||||
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
|
||||
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
_FS_PREVIEW_LANGUAGE_BY_EXT = {
|
||||
".c": "c",
|
||||
".conf": "ini",
|
||||
".cpp": "cpp",
|
||||
".css": "css",
|
||||
".csv": "csv",
|
||||
".go": "go",
|
||||
".graphql": "graphql",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".html": "html",
|
||||
".java": "java",
|
||||
".js": "javascript",
|
||||
".json": "json",
|
||||
".jsx": "jsx",
|
||||
".kt": "kotlin",
|
||||
".lua": "lua",
|
||||
".md": "markdown",
|
||||
".mjs": "javascript",
|
||||
".py": "python",
|
||||
".rb": "ruby",
|
||||
".rs": "rust",
|
||||
".sh": "shell",
|
||||
".sql": "sql",
|
||||
".svg": "xml",
|
||||
".toml": "toml",
|
||||
".ts": "typescript",
|
||||
".tsx": "tsx",
|
||||
".txt": "text",
|
||||
".xml": "xml",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".zsh": "shell",
|
||||
}
|
||||
_FS_MIME_TYPES = {
|
||||
".avi": "video/x-msvideo",
|
||||
".bmp": "image/bmp",
|
||||
".flac": "audio/flac",
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".m4a": "audio/mp4",
|
||||
".mkv": "video/x-matroska",
|
||||
".mov": "video/quicktime",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg; codecs=opus",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".wav": "audio/wav",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
def _fs_path(raw_path: str) -> Path:
|
||||
raw = str(raw_path or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="Path is required")
|
||||
if "\0" in raw:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
try:
|
||||
if raw.lower().startswith("file:"):
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
|
||||
raise ValueError
|
||||
raw = urllib.request.url2pathname(parsed.path)
|
||||
candidate = Path(raw).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = Path.cwd() / candidate
|
||||
return candidate.resolve(strict=False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
|
||||
def _fs_mime_type(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in _FS_MIME_TYPES:
|
||||
return _FS_MIME_TYPES[suffix]
|
||||
guessed, _ = mimetypes.guess_type(str(path))
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
|
||||
def _fs_looks_binary(data: bytes) -> bool:
|
||||
if not data:
|
||||
return False
|
||||
if b"\0" in data:
|
||||
return True
|
||||
suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})
|
||||
return suspicious / len(data) > 0.12
|
||||
|
||||
|
||||
def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
|
||||
target = _fs_path(str(path))
|
||||
try:
|
||||
st = target.stat()
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
except NotADirectoryError:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
|
||||
if stat.S_ISDIR(st.st_mode):
|
||||
raise HTTPException(status_code=400, detail="Path points to a directory")
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
raise HTTPException(status_code=400, detail="Only regular files can be read")
|
||||
return target, st
|
||||
|
||||
|
||||
def _fs_find_git_root(start: Path) -> str | None:
|
||||
directory = start
|
||||
for _ in range(50):
|
||||
try:
|
||||
if (directory / ".git").exists():
|
||||
return str(directory)
|
||||
except OSError:
|
||||
return None
|
||||
parent = directory.parent
|
||||
if parent == directory:
|
||||
return None
|
||||
directory = parent
|
||||
return None
|
||||
|
||||
|
||||
def _fs_default_cwd() -> str:
|
||||
cfg_terminal = load_config().get("terminal") or {}
|
||||
raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip()
|
||||
if raw and raw not in {".", "auto", "cwd"}:
|
||||
try:
|
||||
candidate = Path(raw).expanduser().resolve(strict=False)
|
||||
if candidate.is_dir():
|
||||
return str(candidate)
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
return str(Path.cwd())
|
||||
|
||||
|
||||
def _fs_git_branch(cwd: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", cwd, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _media_serve_roots() -> list[Path]:
|
||||
"""Directories ``GET /api/media`` is allowed to read from.
|
||||
|
||||
@@ -1264,6 +1436,87 @@ async def delete_managed_file(payload: ManagedFileDelete, request: Request):
|
||||
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
|
||||
|
||||
|
||||
@app.get("/api/fs/list")
|
||||
async def fs_list(path: str):
|
||||
target = _fs_path(path)
|
||||
try:
|
||||
entries = []
|
||||
with os.scandir(target) as scan:
|
||||
for entry in scan:
|
||||
if entry.name in _FS_READDIR_HIDDEN:
|
||||
continue
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"path": str(target / entry.name),
|
||||
"isDirectory": entry.is_dir(follow_symlinks=False),
|
||||
})
|
||||
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
|
||||
return {"entries": entries}
|
||||
except FileNotFoundError:
|
||||
return {"entries": [], "error": "ENOENT"}
|
||||
except NotADirectoryError:
|
||||
return {"entries": [], "error": "ENOTDIR"}
|
||||
except PermissionError:
|
||||
return {"entries": [], "error": "EACCES"}
|
||||
except OSError as exc:
|
||||
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
|
||||
|
||||
|
||||
@app.get("/api/fs/read-text")
|
||||
async def fs_read_text(path: str):
|
||||
target, st = _fs_regular_file(_fs_path(path))
|
||||
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)
|
||||
try:
|
||||
with target.open("rb") as handle:
|
||||
data = handle.read(bytes_to_read)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
|
||||
return {
|
||||
"binary": _fs_looks_binary(data[:4096]),
|
||||
"byteSize": st.st_size,
|
||||
"language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"),
|
||||
"mimeType": _fs_mime_type(target),
|
||||
"path": str(target),
|
||||
"text": data.decode("utf-8", errors="replace"),
|
||||
"truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/fs/read-data-url")
|
||||
async def fs_read_data_url(path: str):
|
||||
target, st = _fs_regular_file(_fs_path(path))
|
||||
if st.st_size > _FS_DATA_URL_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File too large")
|
||||
try:
|
||||
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
|
||||
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
|
||||
|
||||
|
||||
@app.get("/api/fs/git-root")
|
||||
async def fs_git_root(path: str):
|
||||
target = _fs_path(path)
|
||||
try:
|
||||
st = target.stat()
|
||||
start = target if stat.S_ISDIR(st.st_mode) else target.parent
|
||||
except OSError:
|
||||
start = target
|
||||
return {"root": _fs_find_git_root(start)}
|
||||
|
||||
|
||||
@app.get("/api/fs/default-cwd")
|
||||
async def fs_default_cwd():
|
||||
cwd = _fs_default_cwd()
|
||||
return {"cwd": cwd, "branch": _fs_git_branch(cwd)}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
@@ -6525,6 +6778,75 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automation Blueprints — parameterized automation blueprints. The dashboard renders the
|
||||
# slot schema as a form; submitting instantiates a real cron job via the same
|
||||
# create_job path. See cron/blueprint_catalog.py for the single source of truth.
|
||||
# ---------------------------------------------------------------------------
|
||||
class AutomationBlueprintInstantiate(BaseModel):
|
||||
blueprint: str # blueprint key, e.g. "morning-brief"
|
||||
values: Dict[str, Any] = {} # filled slot values from the form
|
||||
|
||||
|
||||
@app.get("/api/cron/blueprints")
|
||||
async def list_cron_blueprints():
|
||||
"""Return the blueprint catalog as form schemas for the dashboard gallery.
|
||||
|
||||
The ``deliver`` slot's options are rewritten from the user's actually
|
||||
configured gateway platforms (plus the universal origin/local/all), so the
|
||||
form never offers a platform that isn't connected.
|
||||
"""
|
||||
try:
|
||||
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
|
||||
|
||||
deliver_options = None
|
||||
try:
|
||||
from cron.scheduler import cron_delivery_targets
|
||||
|
||||
platforms = [t["id"] for t in cron_delivery_targets() if t.get("id")]
|
||||
deliver_options = ["origin", "local", *platforms]
|
||||
except Exception:
|
||||
_log.debug("cron_delivery_targets unavailable; using static deliver options", exc_info=True)
|
||||
|
||||
entries = []
|
||||
for r in CATALOG:
|
||||
entry = blueprint_catalog_entry(r)
|
||||
if deliver_options:
|
||||
for f in entry.get("fields", []):
|
||||
if f.get("name") == "deliver":
|
||||
f["options"] = deliver_options
|
||||
entries.append(entry)
|
||||
return {"blueprints": entries}
|
||||
except Exception as e:
|
||||
_log.exception("GET /api/cron/blueprints failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/cron/blueprints/instantiate")
|
||||
async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"):
|
||||
"""Fill a blueprint's slots and create the cron job (form-submit path)."""
|
||||
try:
|
||||
from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError
|
||||
|
||||
blueprint = get_blueprint(body.blueprint)
|
||||
if blueprint is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}")
|
||||
try:
|
||||
spec = fill_blueprint(blueprint, body.values)
|
||||
except BlueprintFillError as exc:
|
||||
# Field-level validation error — 422 so the form can show it inline.
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
# Blueprint-created jobs deliver to the dashboard's configured target by
|
||||
# default; the form's deliver slot overrides via spec["deliver"].
|
||||
spec.pop("origin", None)
|
||||
return _call_cron_for_profile(profile, "create_job", **spec)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_log.exception("POST /api/cron/blueprints/instantiate failed")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP server endpoints — list / add / remove / test.
|
||||
#
|
||||
@@ -9121,11 +9443,18 @@ class RawConfigUpdate(BaseModel):
|
||||
|
||||
@app.get("/api/config/raw")
|
||||
async def get_config_raw(profile: Optional[str] = None):
|
||||
"""Raw config.yaml text plus its resolved path.
|
||||
|
||||
``path`` is resolved inside ``_profile_scope`` so the Config page header
|
||||
shows the file the switched profile actually reads/writes — /api/status's
|
||||
``config_path`` is machine-global and always reports the dashboard
|
||||
process's own profile, which is wrong under the global profile switcher.
|
||||
"""
|
||||
with _profile_scope(profile):
|
||||
path = get_config_path()
|
||||
if not path.exists():
|
||||
return {"yaml": ""}
|
||||
return {"yaml": path.read_text(encoding="utf-8")}
|
||||
return {"yaml": "", "path": str(path)}
|
||||
return {"yaml": path.read_text(encoding="utf-8"), "path": str(path)}
|
||||
|
||||
|
||||
@app.put("/api/config/raw")
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ let
|
||||
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-mVWPJLIYa4EA0iNPiSVLAPzjjnWdky2HbG5mwApy1lo=";
|
||||
npmDepsHash = "sha256-jN6rD+vVhTCWz3lFZzlmFYXmcMRPTtYWy3XVSiDYbvM=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
|
||||
Generated
+32
-17
@@ -102,6 +102,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dnd-core": "^14.0.1",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
@@ -113,6 +114,7 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
@@ -133,7 +135,7 @@
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
@@ -203,6 +205,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -14559,9 +14571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.1.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
|
||||
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
|
||||
"version": "18.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -21418,7 +21430,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
@@ -21891,13 +21903,13 @@
|
||||
}
|
||||
},
|
||||
"ui-tui/node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"ui-tui/node_modules/ansi-regex": {
|
||||
@@ -22024,13 +22036,6 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"ui-tui/node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"ui-tui/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
@@ -22151,7 +22156,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -22210,6 +22215,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"web/node_modules/@types/node": {
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"web/node_modules/globals": {
|
||||
"version": "17.6.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from typing import Callable, Dict, List, Optional, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,6 +69,43 @@ from gateway.platforms.base import (
|
||||
from tools.url_safety import is_safe_url
|
||||
|
||||
|
||||
async def _wait_for_ready_or_bot_exit(
|
||||
ready_event: asyncio.Event,
|
||||
bot_task: asyncio.Task,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
"""Wait until Discord is ready, or surface early bot startup failure.
|
||||
|
||||
``discord.py`` startup errors (including SOCKS/proxy failures from
|
||||
aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()``
|
||||
only waits on ``ready_event``, a dead background task still burns the full
|
||||
ready timeout before the gateway supervisor can reconnect. Racing the ready
|
||||
event against the bot task keeps failures fast and preserves the original
|
||||
exception for logging/classification.
|
||||
"""
|
||||
ready_task = asyncio.create_task(ready_event.wait())
|
||||
try:
|
||||
done, _pending = await asyncio.wait(
|
||||
{ready_task, bot_task},
|
||||
timeout=timeout,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if not done:
|
||||
raise asyncio.TimeoutError
|
||||
if bot_task in done:
|
||||
exc = bot_task.exception()
|
||||
if exc is not None:
|
||||
raise exc
|
||||
if not ready_task.done():
|
||||
raise RuntimeError("Discord bot task exited before ready")
|
||||
await ready_task
|
||||
finally:
|
||||
if not ready_task.done():
|
||||
ready_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ready_task
|
||||
|
||||
|
||||
def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]:
|
||||
"""Return discord.py's bundled Windows opus DLL path when present."""
|
||||
if sys.platform != "win32":
|
||||
@@ -622,6 +660,10 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._typing_tasks: Dict[str, asyncio.Task] = {}
|
||||
self._bot_task: Optional[asyncio.Task] = None
|
||||
self._post_connect_task: Optional[asyncio.Task] = None
|
||||
# True while disconnect() is intentionally closing discord.py. The
|
||||
# bot task's done callback uses this to distinguish an operator/service
|
||||
# shutdown from a runtime websocket crash.
|
||||
self._disconnecting = False
|
||||
# Dedup cache: prevents duplicate bot responses when Discord
|
||||
# RESUME replays events after reconnects.
|
||||
self._dedup = MessageDeduplicator()
|
||||
@@ -634,6 +676,65 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
# scanning channel.history() on cache miss (cold start / restart).
|
||||
self._last_self_message_id: Dict[str, str] = {}
|
||||
|
||||
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
|
||||
"""Surface post-startup discord.py task exits to the gateway supervisor.
|
||||
|
||||
discord.py reconnects normal gateway interruptions internally. When its
|
||||
top-level ``Bot.start()`` task actually exits after the adapter has been
|
||||
marked running, the Discord websocket is dead while the Hermes gateway
|
||||
process can remain alive. Treat that split-brain state as a retryable
|
||||
fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can
|
||||
remove this adapter and queue Discord for the existing reconnect watcher.
|
||||
"""
|
||||
if getattr(self, "_disconnecting", False):
|
||||
# Intentional service/operator shutdown. Drain the task result so
|
||||
# asyncio doesn't emit "exception was never retrieved" warnings.
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.exception()
|
||||
return
|
||||
|
||||
# Ignore stale callbacks from an older client if a reconnect already
|
||||
# installed a newer Bot.start() task on this adapter instance.
|
||||
if self._bot_task is not None and task is not self._bot_task:
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.exception()
|
||||
return
|
||||
|
||||
if not self._running:
|
||||
# Startup failures are handled by _wait_for_ready_or_bot_exit() in
|
||||
# connect(); this callback is only for post-startup split-brain.
|
||||
with suppress(asyncio.CancelledError, Exception):
|
||||
task.exception()
|
||||
return
|
||||
|
||||
try:
|
||||
exc = task.exception()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as err: # pragma: no cover - defensive
|
||||
exc = err
|
||||
|
||||
if exc is None:
|
||||
message = "Discord gateway task exited without an exception"
|
||||
else:
|
||||
message = f"Discord gateway task exited: {exc}"
|
||||
|
||||
logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False)
|
||||
self._set_fatal_error("discord_gateway_task_exited", message, retryable=True)
|
||||
|
||||
async def _notify() -> None:
|
||||
try:
|
||||
await self._notify_fatal_error()
|
||||
except Exception as notify_exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"[%s] Failed to notify gateway supervisor about Discord task exit: %s",
|
||||
self.name,
|
||||
notify_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_notify())
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Discord and start receiving events."""
|
||||
if not DISCORD_AVAILABLE:
|
||||
@@ -900,25 +1001,55 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._register_slash_commands()
|
||||
|
||||
# Start the bot in background
|
||||
self._disconnecting = False
|
||||
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
|
||||
self._bot_task.add_done_callback(self._handle_bot_task_done)
|
||||
|
||||
# Wait for ready
|
||||
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
|
||||
# Wait for ready, but fail fast if discord.py's background startup
|
||||
# task dies first (for example on SOCKS/proxy connect errors).
|
||||
await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30)
|
||||
|
||||
self._running = True
|
||||
return True
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
|
||||
# Cancel the background bot task so it cannot fire on_message after
|
||||
# this adapter is discarded. Without this, the task keeps running and
|
||||
# a later successful reconnect leaves two active Discord clients that
|
||||
# each process every message, producing duplicate threads/responses.
|
||||
await self._cancel_bot_task()
|
||||
self._release_platform_lock()
|
||||
return False
|
||||
except Exception as e: # pragma: no cover - defensive logging
|
||||
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
|
||||
# Same zombie-client hazard as the timeout branch: the background
|
||||
# client.start() task may already be running when a later setup
|
||||
# step raises. Cancel it so the discarded adapter cannot connect.
|
||||
await self._cancel_bot_task()
|
||||
self._release_platform_lock()
|
||||
return False
|
||||
|
||||
async def _cancel_bot_task(self) -> None:
|
||||
"""Cancel and await the background client.start() task, if running."""
|
||||
if self._bot_task and not self._bot_task.done():
|
||||
self._bot_task.cancel()
|
||||
try:
|
||||
await self._bot_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._bot_task = None
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""Disconnect from Discord."""
|
||||
self._disconnecting = True
|
||||
# Cancel the bot task before closing the client. If connect() timed out
|
||||
# and returned False, the background client.start() task may still be
|
||||
# running; calling client.close() alone is not enough to stop it because
|
||||
# discord.py's reconnect loop can ignore the closed flag while a
|
||||
# WebSocket handshake is in flight. Explicitly cancelling the task here
|
||||
# ensures the zombie client cannot receive or dispatch any further events.
|
||||
await self._cancel_bot_task()
|
||||
# Clean up all active voice connections before closing the client
|
||||
for guild_id in list(self._voice_clients.keys()):
|
||||
try:
|
||||
|
||||
+1
-1
@@ -319,7 +319,7 @@ plugins = [
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "cron.*", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -272,8 +272,9 @@ def main():
|
||||
# (well above current catalog size) lets the full catalog land in the
|
||||
# index instead of being truncated at an arbitrary build-time limit.
|
||||
SOURCE_LIMITS = {
|
||||
# ClawHub had 49,698+ skills as of May 2026; 200k leaves headroom.
|
||||
"clawhub": 200_000,
|
||||
# 0 = unbounded catalog walk (max_items=0 in ClawHubSource). A positive
|
||||
# limit bounds the walk and also enables the interactive 12s budget.
|
||||
"clawhub": 0,
|
||||
"lobehub": 100_000,
|
||||
"browse-sh": 5_000,
|
||||
"claude-marketplace": 5_000,
|
||||
|
||||
@@ -63,6 +63,7 @@ AUTHOR_MAP = {
|
||||
"thomas.paquette@gmail.com": "RyTsYdUp",
|
||||
"techxacm@gmail.com": "ProgramCaiCai",
|
||||
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
|
||||
"123150002+deaneeth@users.noreply.github.com": "deaneeth",
|
||||
"157839748+psionic73@users.noreply.github.com": "psionic73",
|
||||
"manishbyatroy@gmail.com": "manishbyatroy",
|
||||
"chilltulpa@gmail.com": "TheGardenGallery",
|
||||
|
||||
@@ -312,6 +312,10 @@ class TestEditFormatSteering:
|
||||
assert "mode='patch'" in brief
|
||||
assert "V4A" in brief
|
||||
assert "write_file" in brief # new files authored, not patched
|
||||
# Codex-family harnesses ship apply_patch (V4A) as the ONLY editor and
|
||||
# instruct it even for single-file edits — never nudge replace mode.
|
||||
assert "single-file" in brief
|
||||
assert "mode='replace'" not in brief
|
||||
|
||||
def test_anthropic_family_gets_replace_nudge(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
@@ -368,21 +372,27 @@ class TestProfiles:
|
||||
assert cc.GENERAL_PROFILE.toolset is None
|
||||
assert cc.GENERAL_PROFILE.guidance == ""
|
||||
|
||||
def test_skill_demotion_scoped_to_coding_posture(self, tmp_path):
|
||||
# Coding posture demotes clearly-non-coding categories to names-only
|
||||
# in the index (never hides them — agent-created skills are the
|
||||
# model's project memory and must stay recallable by name).
|
||||
# Coding-adjacent categories keep full entries (deny-list semantics).
|
||||
def test_skill_demotion_gated_on_focus(self, tmp_path):
|
||||
# Names-only demotion is opt-in via focus mode — the default (auto)
|
||||
# and forced (on) postures leave the skill index untouched. Under
|
||||
# focus, clearly-non-coding categories are demoted (never hidden) and
|
||||
# coding-adjacent ones keep full entries (deny-list semantics).
|
||||
_git_init(tmp_path)
|
||||
for raw in ("auto", "on", "focus"):
|
||||
for raw in ("auto", "on"):
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}
|
||||
)
|
||||
assert mode.is_coding is True
|
||||
compact = mode.compact_skill_categories()
|
||||
assert "social-media" in compact and "smart-home" in compact
|
||||
for kept in ("github", "devops", "software-development", "data-science"):
|
||||
assert kept not in compact
|
||||
assert mode.compact_skill_categories() == frozenset()
|
||||
focus = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path,
|
||||
config={"agent": {"coding_context": "focus"}},
|
||||
)
|
||||
assert focus.is_coding is True
|
||||
compact = focus.compact_skill_categories()
|
||||
assert "social-media" in compact and "smart-home" in compact
|
||||
for kept in ("github", "devops", "software-development", "data-science"):
|
||||
assert kept not in compact
|
||||
# General posture demotes nothing.
|
||||
general = cc.resolve_runtime_mode(platform="telegram", cwd=tmp_path, config={})
|
||||
assert general.compact_skill_categories() == frozenset()
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
|
||||
from agent.context_compressor import (
|
||||
ContextCompressor,
|
||||
HISTORICAL_TASK_HEADING,
|
||||
SUMMARY_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -157,7 +161,7 @@ class TestCompress:
|
||||
result = c.compress(msgs)
|
||||
|
||||
combined = "\n".join(str(m.get("content", "")) for m in result)
|
||||
assert "## Active Task" in combined
|
||||
assert HISTORICAL_TASK_HEADING in combined
|
||||
assert "Please fix the compression summary failure" in combined
|
||||
assert "read_file" in combined
|
||||
assert "agent/context_compressor.py" in combined
|
||||
@@ -1213,7 +1217,8 @@ class TestCompressWithClient:
|
||||
"""When the summary lands as standalone role='user' (e.g. head ends
|
||||
with assistant/tool), the message body must include the explicit
|
||||
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
|
||||
read the verbatim past user request quoted in '## Active Task' as
|
||||
read the verbatim past user request quoted in the historical task
|
||||
snapshot as
|
||||
fresh input (#11475, #14521).
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import hermes_time
|
||||
from agent.context_compressor import ContextCompressor
|
||||
from agent.context_compressor import ContextCompressor, HISTORICAL_TASK_HEADING
|
||||
|
||||
|
||||
def _compressor() -> ContextCompressor:
|
||||
@@ -98,7 +98,7 @@ def test_clock_failure_omits_rule_but_compaction_still_runs():
|
||||
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||
assert "TEMPORAL ANCHORING" not in prompt
|
||||
# Structured template still intact.
|
||||
assert "## Active Task" in prompt
|
||||
assert HISTORICAL_TASK_HEADING in prompt
|
||||
|
||||
|
||||
def test_anchoring_rule_uses_date_from_hermes_time_now():
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Regression coverage for #35344: a resumed session must not let a stale
|
||||
``## Active Task`` from an inherited compaction handoff hijack the reply to a
|
||||
historical task snapshot from an inherited compaction handoff hijack the reply to a
|
||||
new, unrelated user message.
|
||||
|
||||
The failure mode (real report): a lineage was compacted, producing a handoff
|
||||
whose ``## Active Task`` described task A. The lineage was resumed later and
|
||||
whose historical task snapshot described task A. The lineage was resumed later and
|
||||
the user asked about an unrelated task B. The model answered with A because
|
||||
the handoff's resume directive outranked the fresh ask.
|
||||
|
||||
@@ -16,14 +16,15 @@ named reverse-signal verbs. Two invariants guard the resume path specifically:
|
||||
pre-fix stale handoff cannot keep its "resume exactly" directive forever.
|
||||
|
||||
2. The current handoff prefix contains an unambiguous "latest message wins /
|
||||
discard stale Active Task" rule, so an unrelated new ask is privileged over
|
||||
the inherited ``## Active Task``.
|
||||
discard stale historical task" rule, so an unrelated new ask is privileged over
|
||||
the inherited task snapshot.
|
||||
|
||||
These are content/structural assertions (no live model call) — they pin the
|
||||
mechanism that makes the stale task historical rather than active.
|
||||
"""
|
||||
|
||||
from agent.context_compressor import (
|
||||
HISTORICAL_TASK_HEADING,
|
||||
SUMMARY_PREFIX,
|
||||
LEGACY_SUMMARY_PREFIX,
|
||||
ContextCompressor,
|
||||
@@ -48,13 +49,17 @@ _OLD_CONFLICTING_PREFIX = (
|
||||
|
||||
def test_latest_message_wins_over_inherited_active_task():
|
||||
"""The handoff must explicitly privilege the latest user message over a
|
||||
stale ``## Active Task`` — the core #35344 contract."""
|
||||
stale historical task snapshot — the core #35344 contract."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "latest user message" in lower
|
||||
assert "## active task" in lower
|
||||
assert HISTORICAL_TASK_HEADING.lower() in lower
|
||||
# Conflict-resolution must be explicit, not implied.
|
||||
assert "wins" in lower or "supersede" in lower
|
||||
assert "discard" in lower
|
||||
# The "consistent -> use as background" carveout licensed stale-task
|
||||
# resumption on topic overlap (#41607, #38364) — it must stay gone.
|
||||
assert "you may use the summary as background" not in lower
|
||||
assert "topic overlap" in lower
|
||||
|
||||
|
||||
def test_no_resume_exactly_directive_can_hijack():
|
||||
@@ -69,7 +74,7 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
|
||||
prefix when re-normalized on re-compaction — so the "resume exactly"
|
||||
directive cannot survive into a resumed session."""
|
||||
stale_body = (
|
||||
"## Active Task\n"
|
||||
f"{HISTORICAL_TASK_HEADING}\n"
|
||||
"User asked: 'Migrate the billing module to Stripe'\n\n"
|
||||
"## Goal\nMigrate billing.\n"
|
||||
)
|
||||
@@ -86,13 +91,15 @@ def test_resumed_stale_handoff_gets_renormalized_to_current_prefix():
|
||||
# current latest-message-wins framing.
|
||||
assert "resume exactly" not in renormalized.lower()
|
||||
assert renormalized.startswith(SUMMARY_PREFIX)
|
||||
assert "wins" in renormalized.lower()
|
||||
assert ("wins" in renormalized.lower()
|
||||
or "priority" in renormalized.lower()
|
||||
or "supersede" in renormalized.lower())
|
||||
|
||||
|
||||
def test_legacy_prefix_handoff_also_renormalized():
|
||||
"""The same upgrade applies to the oldest ``[CONTEXT SUMMARY]:`` handoff
|
||||
format that may sit in a long-lived resumed lineage."""
|
||||
legacy = f"{LEGACY_SUMMARY_PREFIX} ## Active Task\nUser asked: 'task A'"
|
||||
legacy = f"{LEGACY_SUMMARY_PREFIX} {HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"
|
||||
renormalized = ContextCompressor._with_summary_prefix(legacy)
|
||||
assert renormalized.startswith(SUMMARY_PREFIX)
|
||||
assert LEGACY_SUMMARY_PREFIX not in renormalized
|
||||
@@ -107,7 +114,7 @@ def test_inherited_handoff_detected_in_resumed_protected_head():
|
||||
Task read as live intent)."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{SUMMARY_PREFIX}\n## Active Task\nUser asked: 'task A'"},
|
||||
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "Unrelated task B: what's the capital of France?"},
|
||||
]
|
||||
@@ -129,7 +136,7 @@ def test_historical_prefixed_handoff_detected_and_stripped():
|
||||
stale 'resume exactly' text as a fresh turn."""
|
||||
messages = [
|
||||
{"role": "system", "content": "system prompt"},
|
||||
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n## Active Task\nUser asked: 'task A'"},
|
||||
{"role": "user", "content": f"{_OLD_CONFLICTING_PREFIX}\n{HISTORICAL_TASK_HEADING}\nUser asked: 'task A'"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
{"role": "user", "content": "Unrelated task B"},
|
||||
]
|
||||
|
||||
@@ -18,7 +18,13 @@ the agent repeatedly re-surfacing already-cancelled work across turns.
|
||||
These tests pin the post-fix invariants so the conflict cannot regress.
|
||||
"""
|
||||
|
||||
from agent.context_compressor import SUMMARY_PREFIX
|
||||
from agent.context_compressor import (
|
||||
HISTORICAL_IN_PROGRESS_HEADING,
|
||||
HISTORICAL_PENDING_ASKS_HEADING,
|
||||
HISTORICAL_REMAINING_WORK_HEADING,
|
||||
HISTORICAL_TASK_HEADING,
|
||||
SUMMARY_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def test_no_resume_exactly_directive():
|
||||
@@ -30,8 +36,22 @@ def test_latest_message_wins_on_conflict():
|
||||
"""The prefix must explicitly say latest user message wins on conflict."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "latest user message" in lower
|
||||
assert HISTORICAL_TASK_HEADING.lower() in lower
|
||||
assert HISTORICAL_PENDING_ASKS_HEADING.lower() in lower
|
||||
assert HISTORICAL_REMAINING_WORK_HEADING.lower() in lower
|
||||
# Must have an explicit conflict-resolution rule.
|
||||
assert "wins" in lower or "supersede" in lower or "discard" in lower
|
||||
assert "wins" in lower or "supersede" in lower or "discard" in lower or "priority" in lower
|
||||
|
||||
|
||||
def test_handoff_sections_are_framed_as_historical():
|
||||
"""The summary headings referenced in the prefix must sound historical,
|
||||
not like live instructions for the current turn."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "## active task" not in lower
|
||||
assert "## pending user asks" not in lower
|
||||
assert "## remaining work" not in lower
|
||||
assert HISTORICAL_TASK_HEADING.lower() in lower
|
||||
assert HISTORICAL_IN_PROGRESS_HEADING.lower() in lower
|
||||
|
||||
|
||||
def test_reverse_signals_called_out():
|
||||
@@ -60,3 +80,37 @@ def test_memory_authority_preserved():
|
||||
assert "MEMORY.md" in SUMMARY_PREFIX
|
||||
assert "USER.md" in SUMMARY_PREFIX
|
||||
assert "authoritative" in SUMMARY_PREFIX
|
||||
|
||||
|
||||
def test_no_background_consistency_carveout():
|
||||
"""The "consistent → use as background" carveout licensed stale-task
|
||||
resumption on topic overlap (#41607, #38364, #42812). It must stay gone,
|
||||
and the prefix must explicitly neutralize topic overlap."""
|
||||
lower = SUMMARY_PREFIX.lower()
|
||||
assert "you may use the summary as background" not in lower
|
||||
assert "topic overlap" in lower
|
||||
|
||||
|
||||
def test_replaced_prefixes_are_frozen_for_renormalization():
|
||||
"""Every retired SUMMARY_PREFIX must be frozen into
|
||||
_HISTORICAL_SUMMARY_PREFIXES, otherwise summaries persisted by older
|
||||
builds lose detection/renormalization after an upgrade. The carveout-era
|
||||
prefix is the latest retiree."""
|
||||
from agent.context_compressor import (
|
||||
_HISTORICAL_SUMMARY_PREFIXES,
|
||||
ContextCompressor,
|
||||
)
|
||||
|
||||
carveout_era = [
|
||||
p for p in _HISTORICAL_SUMMARY_PREFIXES
|
||||
if "you may use the summary as background" in p
|
||||
]
|
||||
assert carveout_era, "carveout-era prefix missing from frozen tuple"
|
||||
# The live prefix must never be one of the frozen ones.
|
||||
assert SUMMARY_PREFIX not in _HISTORICAL_SUMMARY_PREFIXES
|
||||
# Detection + strip must work for every frozen prefix.
|
||||
for old_prefix in _HISTORICAL_SUMMARY_PREFIXES:
|
||||
content = old_prefix + "\n## Summary body"
|
||||
assert ContextCompressor._is_context_summary_content(content)
|
||||
stripped = ContextCompressor._strip_summary_prefix(content)
|
||||
assert not stripped.startswith(old_prefix)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for Automation Blueprints — the parameterized automation blueprint system.
|
||||
|
||||
Covers the core catalog/slot schema/renderers/fill (cron/blueprint_catalog.py),
|
||||
the shared /blueprint command handler (hermes_cli/blueprint_cmd.py), and
|
||||
the docs generator. Uses an isolated HERMES_HOME for anything that touches the
|
||||
cron job store.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cron.blueprint_catalog import (
|
||||
CATALOG,
|
||||
BlueprintFillError,
|
||||
BlueprintSlot,
|
||||
fill_blueprint,
|
||||
get_blueprint,
|
||||
blueprint_catalog_entry,
|
||||
blueprint_deeplink,
|
||||
blueprint_form_schema,
|
||||
blueprint_slash_command,
|
||||
)
|
||||
|
||||
|
||||
class TestCatalog:
|
||||
def test_catalog_nonempty_and_keyed(self):
|
||||
assert len(CATALOG) >= 1
|
||||
for r in CATALOG:
|
||||
assert get_blueprint(r.key) is r
|
||||
|
||||
def test_every_slot_has_known_type(self):
|
||||
for r in CATALOG:
|
||||
for s in r.slots:
|
||||
assert s.type in {"time", "enum", "text", "weekdays"}
|
||||
|
||||
def test_bad_slot_type_rejected(self):
|
||||
with pytest.raises(ValueError):
|
||||
BlueprintSlot(name="x", type="bogus", label="X")
|
||||
|
||||
|
||||
class TestScheduleResolution:
|
||||
def test_time_to_cron(self):
|
||||
spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:30"})
|
||||
assert spec["schedule"] == "30 8 * * *"
|
||||
|
||||
def test_interval_schedule(self):
|
||||
spec = fill_blueprint(
|
||||
get_blueprint("important-mail"),
|
||||
{"interval_min": "15", "criteria": "x", "deliver": "origin"},
|
||||
)
|
||||
assert spec["schedule"] == "*/15 * * * *"
|
||||
|
||||
def test_day_to_dow(self):
|
||||
spec = fill_blueprint(
|
||||
get_blueprint("weekly-review"),
|
||||
{"time": "18:00", "day": "sunday", "deliver": "origin"},
|
||||
)
|
||||
assert spec["schedule"] == "0 18 * * 0"
|
||||
|
||||
def test_weekday_preset_to_dow(self):
|
||||
spec = fill_blueprint(
|
||||
get_blueprint("custom-reminder"),
|
||||
{"what": "stretch", "time": "14:00", "recurrence": "weekdays", "deliver": "origin"},
|
||||
)
|
||||
assert spec["schedule"] == "0 14 * * 1-5"
|
||||
|
||||
def test_defaults_fill_when_omitted(self):
|
||||
spec = fill_blueprint(get_blueprint("morning-brief"), {})
|
||||
assert spec["schedule"] == "0 8 * * *"
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_invalid_time_rejected(self):
|
||||
with pytest.raises(BlueprintFillError, match="invalid time"):
|
||||
fill_blueprint(get_blueprint("morning-brief"), {"time": "25:99"})
|
||||
|
||||
def test_bad_enum_rejected_and_names_slot(self):
|
||||
with pytest.raises(BlueprintFillError, match="not allowed"):
|
||||
fill_blueprint(get_blueprint("news-digest"), {"count": "42"})
|
||||
|
||||
def test_deliver_slot_accepts_any_platform(self):
|
||||
# deliver is a non-strict enum: its options are suggestions, the real
|
||||
# set of valid platforms depends on the user's configured gateways and
|
||||
# is validated downstream by the cron scheduler.
|
||||
spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:00", "deliver": "slack"})
|
||||
assert spec["deliver"] == "slack"
|
||||
|
||||
def test_unknown_slot_name_rejected(self):
|
||||
# A typo'd slot must NOT silently create a job with the default value.
|
||||
with pytest.raises(BlueprintFillError, match="unknown slot"):
|
||||
fill_blueprint(get_blueprint("morning-brief"), {"tiem": "07:15"})
|
||||
|
||||
def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self):
|
||||
# Regression: a minute-field step (*/90) silently wraps to hourly.
|
||||
# The hour-field step form must produce the cadence the user picked.
|
||||
croniter = pytest.importorskip("croniter").croniter
|
||||
from datetime import datetime
|
||||
|
||||
spec = fill_blueprint(get_blueprint("hydration-move"), {"interval_hours": "2"})
|
||||
it = croniter(spec["schedule"], datetime(2026, 6, 10, 8, 0))
|
||||
first_three = [it.get_next(datetime) for _ in range(3)]
|
||||
gaps = {
|
||||
(b - a).total_seconds()
|
||||
for a, b in zip(first_three, first_three[1:])
|
||||
}
|
||||
assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}"
|
||||
|
||||
def test_text_slot_renders_into_prompt(self):
|
||||
spec = fill_blueprint(
|
||||
get_blueprint("important-mail"),
|
||||
{"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"},
|
||||
)
|
||||
assert "from my CEO" in spec["prompt"]
|
||||
|
||||
def test_origin_threads_through(self):
|
||||
spec = fill_blueprint(
|
||||
get_blueprint("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"}
|
||||
)
|
||||
assert spec["origin"] == {"platform": "telegram", "chat_id": "9"}
|
||||
|
||||
|
||||
class TestRenderers:
|
||||
def test_form_schema_fields(self):
|
||||
schema = blueprint_form_schema(get_blueprint("morning-brief"))
|
||||
names = [f["name"] for f in schema["fields"]]
|
||||
assert names == ["time", "deliver"]
|
||||
assert schema["key"] == "morning-brief"
|
||||
|
||||
def test_slash_command_defaults(self):
|
||||
cmd = blueprint_slash_command(get_blueprint("morning-brief"))
|
||||
assert cmd.startswith("/blueprint morning-brief")
|
||||
assert "time=08:00" in cmd
|
||||
|
||||
def test_slash_command_quotes_freetext(self):
|
||||
cmd = blueprint_slash_command(
|
||||
get_blueprint("custom-reminder"), {"what": "drink water", "time": "10:00"}
|
||||
)
|
||||
assert '"drink water"' in cmd
|
||||
|
||||
def test_deeplink_shape(self):
|
||||
url = blueprint_deeplink(get_blueprint("morning-brief"), {"time": "07:15"})
|
||||
assert url.startswith("hermes://blueprint/morning-brief?")
|
||||
assert "time=07" in url
|
||||
|
||||
def test_catalog_entry_has_all_surfaces(self):
|
||||
entry = blueprint_catalog_entry(get_blueprint("morning-brief"))
|
||||
assert entry["command"].startswith("/blueprint")
|
||||
assert entry["appUrl"].startswith("hermes://")
|
||||
assert entry["scheduleHuman"]
|
||||
assert "fields" in entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
import hermes_constants
|
||||
importlib.reload(hermes_constants)
|
||||
import cron.jobs as jobs
|
||||
importlib.reload(jobs)
|
||||
return jobs
|
||||
|
||||
|
||||
class TestCommandHandler:
|
||||
def test_bare_lists_catalog(self, isolated_home):
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
|
||||
res = handle_blueprint_command("")
|
||||
assert "morning-brief" in res.text and "Automation Blueprints" in res.text
|
||||
assert res.agent_seed is None
|
||||
|
||||
def test_name_seeds_agent(self, isolated_home):
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
|
||||
# `/blueprint <name>` (no inline slots) now seeds the agent to ask
|
||||
# the user for each value conversationally instead of dumping fields.
|
||||
res = handle_blueprint_command("morning-brief")
|
||||
assert res.agent_seed is not None
|
||||
assert "morning-brief" in res.agent_seed
|
||||
assert "cronjob tool" in res.agent_seed
|
||||
# the schedule template is handed to the agent to build the cron expr
|
||||
assert "* * *" in res.agent_seed
|
||||
|
||||
def test_name_match_is_forgiving(self, isolated_home):
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command, match_blueprint
|
||||
|
||||
# prefix match
|
||||
r, cands = match_blueprint("morning")
|
||||
assert r is not None and r.key == "morning-brief"
|
||||
# fuzzy / typo
|
||||
r2, _ = match_blueprint("mornning-brief")
|
||||
assert r2 is not None and r2.key == "morning-brief"
|
||||
# a forgiving name still seeds the agent
|
||||
res = handle_blueprint_command("morning")
|
||||
assert res.agent_seed is not None
|
||||
|
||||
def test_fill_creates_job(self, isolated_home):
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
|
||||
res = handle_blueprint_command("morning-brief time=07:30 deliver=telegram")
|
||||
assert "Scheduled" in res.text
|
||||
assert res.agent_seed is None
|
||||
jobs = isolated_home.load_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *"
|
||||
assert jobs[0].get("deliver") == "telegram"
|
||||
|
||||
def test_unknown_blueprint(self, isolated_home):
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
|
||||
res = handle_blueprint_command("zzz-nope-nothing")
|
||||
assert "No automation blueprint" in res.text
|
||||
assert res.agent_seed is None
|
||||
|
||||
def test_bad_value_names_slot(self, isolated_home):
|
||||
from hermes_cli.blueprint_cmd import handle_blueprint_command
|
||||
|
||||
res = handle_blueprint_command("morning-brief time=99:99")
|
||||
assert "Can't set up" in res.text and "time" in res.text
|
||||
assert res.agent_seed is None
|
||||
|
||||
|
||||
class TestDocsGenerator:
|
||||
def test_generator_emits_valid_index(self, tmp_path):
|
||||
# The generator imports the catalog and writes a flat JSON array.
|
||||
import importlib.util
|
||||
|
||||
script = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "website" / "scripts" / "extract-automation-blueprints.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("extract_cron_blueprints", script)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
index = mod.build_index()
|
||||
assert isinstance(index, list) and len(index) == len(CATALOG)
|
||||
# Each entry must round-trip through json and carry the surfaces.
|
||||
json.dumps(index)
|
||||
assert all("command" in e and "appUrl" in e for e in index)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for the Suggested Cron Jobs feature.
|
||||
|
||||
Covers the store (add/dedup/cap/accept/dismiss/latch), catalog seeding, the
|
||||
blueprint->suggestion bridge, and the shared command handler. Uses an isolated
|
||||
HERMES_HOME so the real suggestions.json is never touched.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""A cron.suggestions module bound to an isolated HERMES_HOME."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# Reload so module-level CRON_DIR/SUGGESTIONS_FILE pick up the temp home.
|
||||
import hermes_constants
|
||||
importlib.reload(hermes_constants)
|
||||
import cron.suggestions as s
|
||||
importlib.reload(s)
|
||||
return s
|
||||
|
||||
|
||||
def _add(store, key="k1", title="Test", source="catalog", schedule="0 9 * * *"):
|
||||
return store.add_suggestion(
|
||||
title=title,
|
||||
description="desc",
|
||||
source=source,
|
||||
job_spec={"prompt": "do it", "schedule": schedule, "name": title, "deliver": "origin"},
|
||||
dedup_key=key,
|
||||
)
|
||||
|
||||
|
||||
class TestStore:
|
||||
def test_add_and_list_pending(self, store):
|
||||
rec = _add(store)
|
||||
assert rec is not None
|
||||
pending = store.list_pending()
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["title"] == "Test"
|
||||
assert pending[0]["status"] == "pending"
|
||||
|
||||
def test_dedup_blocks_duplicate_pending(self, store):
|
||||
assert _add(store, key="dup") is not None
|
||||
assert _add(store, key="dup") is None # same key already pending
|
||||
assert len(store.list_pending()) == 1
|
||||
|
||||
def test_dismiss_latches_against_redisplay(self, store):
|
||||
_add(store, key="latch")
|
||||
assert store.dismiss_suggestion("1") is True
|
||||
assert store.list_pending() == []
|
||||
# Re-adding the same key is refused (never re-offer a dismissed one).
|
||||
assert _add(store, key="latch") is None
|
||||
|
||||
def test_unknown_source_rejected(self, store):
|
||||
with pytest.raises(ValueError):
|
||||
store.add_suggestion(title="x", description="d", source="bogus", job_spec={}, dedup_key="k")
|
||||
|
||||
def test_pending_cap(self, store):
|
||||
for i in range(store.MAX_PENDING):
|
||||
assert _add(store, key=f"k{i}") is not None
|
||||
# One past the cap is dropped.
|
||||
assert _add(store, key="over") is None
|
||||
assert len(store.list_pending()) == store.MAX_PENDING
|
||||
|
||||
def test_accept_creates_job_and_marks_accepted(self, store):
|
||||
_add(store, key="acc", title="My Job")
|
||||
created = {}
|
||||
|
||||
def fake_create_job(**kwargs):
|
||||
created.update(kwargs)
|
||||
return {"id": "job123", "name": kwargs.get("name"), **kwargs}
|
||||
|
||||
with patch("cron.jobs.create_job", fake_create_job):
|
||||
job = store.accept_suggestion("1", origin={"platform": "telegram", "chat_id": "5"})
|
||||
|
||||
assert job is not None
|
||||
assert created["schedule"] == "0 9 * * *"
|
||||
assert created["origin"] == {"platform": "telegram", "chat_id": "5"}
|
||||
# No longer pending.
|
||||
assert store.list_pending() == []
|
||||
# And accepting again is a no-op (not pending anymore).
|
||||
assert store.accept_suggestion("acc") is None
|
||||
|
||||
def test_get_by_id_and_index_and_title(self, store):
|
||||
rec = _add(store, key="byref", title="Findable")
|
||||
assert store.get_suggestion(rec["id"])["id"] == rec["id"]
|
||||
assert store.get_suggestion("1")["id"] == rec["id"]
|
||||
assert store.get_suggestion("findable")["id"] == rec["id"]
|
||||
assert store.get_suggestion("nope") is None
|
||||
|
||||
def test_clear_resolved_drops_accepted_only(self, store):
|
||||
_add(store, key="a")
|
||||
_add(store, key="b")
|
||||
store.dismiss_suggestion("2") # b dismissed (retained for latch)
|
||||
with patch("cron.jobs.create_job", lambda **k: {"id": "j"}):
|
||||
store.accept_suggestion("1") # a accepted
|
||||
removed = store.clear_resolved()
|
||||
assert removed == 1 # only the accepted record pruned
|
||||
# Dismissed record retained so its dedup_key still latches.
|
||||
assert _add(store, key="b") is None
|
||||
|
||||
|
||||
class TestCatalog:
|
||||
def test_seed_registers_all_entries(self, store):
|
||||
from cron.suggestion_catalog import CATALOG, seed_catalog_suggestions
|
||||
|
||||
created = seed_catalog_suggestions(add_fn=store.add_suggestion)
|
||||
assert len(created) == len(CATALOG)
|
||||
assert len(store.list_pending()) == min(len(CATALOG), store.MAX_PENDING)
|
||||
|
||||
def test_seed_is_idempotent(self, store):
|
||||
from cron.suggestion_catalog import seed_catalog_suggestions
|
||||
|
||||
first = seed_catalog_suggestions(add_fn=store.add_suggestion)
|
||||
second = seed_catalog_suggestions(add_fn=store.add_suggestion)
|
||||
assert len(first) >= 1
|
||||
assert second == [] # already present -> nothing new
|
||||
|
||||
def test_monitor_entry_references_classifier_script(self):
|
||||
from cron.suggestion_catalog import CATALOG, classify_items_script_path
|
||||
|
||||
monitor = next(e for e in CATALOG if e.key == "catalog:important-mail-monitor")
|
||||
# The prompt must reference the classifier by module path (resolvable
|
||||
# at run time on any backend), never by a baked-in absolute path —
|
||||
# absolute paths go stale after relocation and don't exist on remote
|
||||
# terminal backends (Docker/Modal).
|
||||
assert "cron.scripts.classify_items" in monitor.job_spec["prompt"]
|
||||
assert classify_items_script_path() not in monitor.job_spec["prompt"]
|
||||
assert Path(classify_items_script_path()).name == "classify_items.py"
|
||||
|
||||
|
||||
class TestBlueprintBridge:
|
||||
def test_blueprint_registers_suggestion(self, store):
|
||||
from tools.blueprints import BlueprintSpec, register_blueprint_suggestion
|
||||
|
||||
spec = BlueprintSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram")
|
||||
with patch("cron.suggestions.add_suggestion", store.add_suggestion):
|
||||
rec = register_blueprint_suggestion(spec)
|
||||
assert rec is not None
|
||||
assert rec["source"] == "blueprint"
|
||||
assert rec["job_spec"]["skills"] == ["morning-brief"]
|
||||
assert rec["job_spec"]["schedule"] == "0 8 * * *"
|
||||
|
||||
def test_blueprint_to_job_spec_matches_create_blueprint_job(self):
|
||||
from tools.blueprints import BlueprintSpec, blueprint_to_job_spec
|
||||
|
||||
spec = BlueprintSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p")
|
||||
js = blueprint_to_job_spec(spec)
|
||||
assert js["skills"] == ["x"]
|
||||
assert js["schedule"] == "every 2h"
|
||||
assert js["prompt"] == "p"
|
||||
|
||||
|
||||
class TestCommandHandler:
|
||||
def test_bare_lists_pending(self, store):
|
||||
_add(store, key="c1", title="Daily thing")
|
||||
with patch("cron.suggestions.list_pending", store.list_pending):
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
# Patch the module the handler imports.
|
||||
with patch.dict("sys.modules"):
|
||||
out = handle_suggestions_command("")
|
||||
assert "Daily thing" in out
|
||||
|
||||
def test_accept_via_handler(self, store):
|
||||
_add(store, key="ha", title="Acceptable")
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
|
||||
with patch("cron.jobs.create_job", lambda **k: {"id": "j", "name": k.get("name"), "job_spec": k}):
|
||||
out = handle_suggestions_command("accept 1", origin={"platform": "cli", "chat_id": "1"})
|
||||
assert "Scheduled" in out
|
||||
assert store.list_pending() == []
|
||||
|
||||
def test_dismiss_via_handler(self, store):
|
||||
_add(store, key="hd", title="Dismissable")
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
|
||||
out = handle_suggestions_command("dismiss 1")
|
||||
assert "Dismissed" in out
|
||||
assert store.list_pending() == []
|
||||
|
||||
def test_empty_list_message(self, store):
|
||||
from hermes_cli.suggestions_cmd import handle_suggestions_command
|
||||
|
||||
out = handle_suggestions_command("")
|
||||
assert "No suggested automations" in out
|
||||
|
||||
def test_aux_monitor_config_default(self):
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
assert "monitor" in DEFAULT_CONFIG["auxiliary"]
|
||||
assert DEFAULT_CONFIG["auxiliary"]["monitor"]["provider"] == "auto"
|
||||
@@ -266,11 +266,12 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_wait_for(awaitable, timeout):
|
||||
awaitable.close()
|
||||
async def fake_wait_for_ready(ready_event, bot_task, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(discord_platform.asyncio, "wait_for", fake_wait_for)
|
||||
monkeypatch.setattr(
|
||||
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
|
||||
)
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
@@ -279,6 +280,89 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_timeout_cancels_bot_task(monkeypatch):
|
||||
"""Regression: connect() timeout must cancel _bot_task so the zombie
|
||||
Discord client cannot fire on_message after the adapter is discarded.
|
||||
|
||||
Without this fix, the orphaned task eventually completes its WebSocket
|
||||
handshake and a subsequent successful reconnect leaves two live clients
|
||||
that each process every message, producing duplicate threads.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
|
||||
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
|
||||
|
||||
intents = SimpleNamespace(
|
||||
message_content=False, dm_messages=False, guild_messages=False,
|
||||
members=False, voice_states=False,
|
||||
)
|
||||
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
|
||||
|
||||
class NeverReadyBot(FakeBot):
|
||||
"""Bot whose start() never fires on_ready — simulates a slow gateway handshake."""
|
||||
async def start(self, token):
|
||||
await asyncio.Event().wait() # hang forever
|
||||
|
||||
monkeypatch.setattr(
|
||||
discord_platform.commands,
|
||||
"Bot",
|
||||
lambda **kwargs: NeverReadyBot(
|
||||
intents=kwargs["intents"],
|
||||
proxy=kwargs.get("proxy"),
|
||||
allowed_mentions=kwargs.get("allowed_mentions"),
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_wait_for_ready(ready_event, bot_task, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(
|
||||
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
|
||||
)
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
assert ok is False
|
||||
assert adapter._bot_task is None, (
|
||||
"_bot_task must be cancelled and cleared on connect() timeout; "
|
||||
"leaving it alive creates a zombie Discord client that produces duplicate threads"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_cancels_running_bot_task(monkeypatch):
|
||||
"""Regression: disconnect() must cancel _bot_task even when connect() timed out.
|
||||
|
||||
_dispose_unused_adapter calls disconnect() on adapters whose connect() returned
|
||||
False. If _bot_task was still running (zombie), disconnect() must cancel it.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
|
||||
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
|
||||
|
||||
# Simulate a zombie bot_task that never finishes (as if discord.py is mid-handshake)
|
||||
async def _forever():
|
||||
await asyncio.Event().wait() # hang forever
|
||||
|
||||
zombie_task = asyncio.create_task(_forever())
|
||||
adapter._bot_task = zombie_task
|
||||
adapter._client = AsyncMock()
|
||||
adapter._post_connect_task = None
|
||||
adapter._voice_clients = {}
|
||||
adapter._running = True
|
||||
adapter._ready_event = asyncio.Event()
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
# The task must have been cancelled (done + cancelled) and cleared from the adapter.
|
||||
assert adapter._bot_task is None, "disconnect() must clear _bot_task"
|
||||
assert zombie_task.done(), "disconnect() must have awaited the bot task to completion"
|
||||
assert zombie_task.cancelled(), "disconnect() must cancel the zombie bot task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests for the document context note prepended to user turns with attachments.
|
||||
|
||||
A user who attaches a PDF / DOCX in chat used to see the agent treat it as
|
||||
"unreadable" because the context note told the model to "Ask the user what
|
||||
they'd like you to do with it" — steering it away from extracting the text it
|
||||
is perfectly capable of reading. These tests pin the contract:
|
||||
|
||||
- text documents: note confirms the (adapter-)inlined content + records path.
|
||||
- binary documents (PDF/DOCX/…): note tells the agent to extract the text
|
||||
itself and never tells it to punt back to the user.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
_build_document_context_note = gateway_run._build_document_context_note
|
||||
|
||||
|
||||
class TestTextDocumentNote:
|
||||
@pytest.mark.parametrize("mtype", ["text/plain", "text/markdown", "text/csv"])
|
||||
def test_text_note_mentions_included_content_and_path(self, mtype):
|
||||
note = _build_document_context_note("notes.txt", "/cache/doc_notes.txt", mtype)
|
||||
assert "text document" in note
|
||||
assert "notes.txt" in note
|
||||
assert "/cache/doc_notes.txt" in note
|
||||
assert "included below" in note
|
||||
|
||||
|
||||
class TestBinaryDocumentNote:
|
||||
@pytest.mark.parametrize(
|
||||
"mtype",
|
||||
[
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/octet-stream",
|
||||
],
|
||||
)
|
||||
def test_binary_note_guides_extraction(self, mtype):
|
||||
note = _build_document_context_note("contract.pdf", "/cache/doc_contract.pdf", mtype)
|
||||
# Records the path so the agent can open it.
|
||||
assert "/cache/doc_contract.pdf" in note
|
||||
# Tells the agent to read it by extracting the text...
|
||||
assert "extract" in note.lower()
|
||||
# ...and does NOT steer it into punting back to the user (the bug).
|
||||
assert "ask the user" not in note.lower()
|
||||
assert "paste" in note.lower()
|
||||
|
||||
def test_binary_note_distinct_from_text_note(self):
|
||||
text_note = _build_document_context_note("a.txt", "/c/a.txt", "text/plain")
|
||||
pdf_note = _build_document_context_note("a.pdf", "/c/a.pdf", "application/pdf")
|
||||
assert text_note != pdf_note
|
||||
# The text path claims content is inlined; the binary path must not.
|
||||
assert "included below" in text_note
|
||||
assert "included below" not in pdf_note
|
||||
@@ -134,6 +134,10 @@ async def test_audio_attachment_context_note_format():
|
||||
assert "audio file attachment" in result.lower()
|
||||
# Should NOT contain the voice-message transcription wrapper text
|
||||
assert "voice message" not in result.lower()
|
||||
# Guides the agent to transcribe/process the file itself rather than
|
||||
# punting back to the user (same bug class as the PDF/DOCX note).
|
||||
assert "transcri" in result.lower()
|
||||
assert "ask the user what they'd like" not in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -336,20 +336,22 @@ class TestSlackNativeSlashes:
|
||||
)
|
||||
|
||||
def test_includes_aliases_as_first_class_slashes(self):
|
||||
"""Aliases (/btw, /bg, /reset, …) must be registered as standalone
|
||||
"""Aliases (/btw, /bg, …) must be registered as standalone
|
||||
slashes — this is the whole point of native-slashes parity.
|
||||
|
||||
Asserts the contract (aliases are surfaced as first-class slashes),
|
||||
not a specific alias's survival of Slack's 50-slash clamp — which alias
|
||||
lands last shifts whenever a canonical command is added, so pinning one
|
||||
name (previously ``q``) made this a change-detector.
|
||||
lands last shifts whenever a canonical command is added. Only the
|
||||
explicitly pinned ``_SLACK_PRIORITY_ALIASES`` are guaranteed slots;
|
||||
every other alias (e.g. ``reset``) may be clamped once the registry
|
||||
fills the cap — canonical commands win the contest, and clamped
|
||||
aliases stay reachable via ``/hermes <alias>``.
|
||||
"""
|
||||
slashes = slack_native_slashes()
|
||||
names = {n for n, _d, _h in slashes}
|
||||
# Aliases that sort early in the registry always fit under the cap.
|
||||
# The pinned priority aliases are guaranteed to survive the clamp.
|
||||
assert "btw" in names
|
||||
assert "bg" in names
|
||||
assert "reset" in names
|
||||
# And at least one alias is surfaced as an alias entry (description
|
||||
# carries the "Alias for /…" marker), proving the alias pass ran.
|
||||
assert any(d.startswith("Alias for /") for _n, d, _h in slashes)
|
||||
|
||||
@@ -82,6 +82,29 @@ class TestUnifiedDashboardRouting:
|
||||
# Profile HERMES_HOME dropped so the child binds the machine root.
|
||||
assert "HERMES_HOME" not in env
|
||||
|
||||
def test_desktop_profile_backend_skips_machine_dashboard_reroute(self, main_mod, monkeypatch):
|
||||
"""A desktop-spawned named-profile backend (HERMES_DESKTOP=1) must NOT
|
||||
reroute into the machine dashboard. The reroute re-execs as the default
|
||||
profile and exits, so the desktop never sees a ready backend → boot
|
||||
loop. The guard keeps desktop pool backends per-profile."""
|
||||
monkeypatch.setenv("HERMES_DESKTOP", "1")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
)
|
||||
listening_calls = []
|
||||
monkeypatch.setattr(
|
||||
main_mod, "_dashboard_listening",
|
||||
lambda host, port: listening_calls.append(1) or False,
|
||||
)
|
||||
execs = []
|
||||
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
||||
monkeypatch.setitem(sys.modules, "fastapi", None)
|
||||
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args())
|
||||
assert listening_calls == []
|
||||
assert execs == []
|
||||
|
||||
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
||||
@@ -128,3 +151,45 @@ class TestUnifiedDashboardRouting:
|
||||
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
||||
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
|
||||
assert execs == []
|
||||
|
||||
def test_dashboard_starts_mcp_discovery_for_ws_backend(self, main_mod, monkeypatch):
|
||||
"""The dashboard process serves the /api/ws gateway but never runs
|
||||
tui_gateway/entry.py, so it must kick off MCP discovery itself or
|
||||
desktop sessions never see a profile's MCP tools."""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
|
||||
)
|
||||
monkeypatch.delenv("HERMES_WEB_DIST", raising=False)
|
||||
monkeypatch.setattr(main_mod, "_sync_bundled_skills_quietly", lambda: None)
|
||||
monkeypatch.setattr(main_mod, "_build_web_ui", lambda *_a, **_k: True)
|
||||
monkeypatch.setitem(sys.modules, "fastapi", types.SimpleNamespace())
|
||||
monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_logging",
|
||||
types.SimpleNamespace(setup_logging=lambda **_k: None),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.plugins",
|
||||
types.SimpleNamespace(discover_plugins=lambda: None),
|
||||
)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_startup.start_background_mcp_discovery",
|
||||
lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli.web_server",
|
||||
types.SimpleNamespace(start_server=lambda **_kwargs: None),
|
||||
)
|
||||
|
||||
main_mod.cmd_dashboard(_args())
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"logger": main_mod.logger,
|
||||
"thread_name": "dashboard-mcp-discovery",
|
||||
}
|
||||
]
|
||||
|
||||
@@ -365,6 +365,40 @@ class TestPluginDiscovery:
|
||||
}
|
||||
assert len(non_bundled) == 1
|
||||
|
||||
def test_failed_discovery_is_not_cached(self, tmp_path, monkeypatch):
|
||||
"""A sweep that raises must not cache 'discovered' with no plugins.
|
||||
|
||||
Regression for the stranded-empty-registry class of failures: callers
|
||||
(e.g. tools.web_tools._ensure_web_plugins_loaded) swallow discovery
|
||||
exceptions as warnings, so if a failed sweep flipped ``_discovered``
|
||||
permanently, every later call would early-return against an empty
|
||||
registry ("No web provider configured") for the process lifetime.
|
||||
"""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(plugins_dir, "retry_plugin")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
mgr = PluginManager()
|
||||
|
||||
def _boom(self_inner):
|
||||
raise RuntimeError("sweep failed")
|
||||
|
||||
monkeypatch.setattr(PluginManager, "_discover_and_load_inner", _boom)
|
||||
with pytest.raises(RuntimeError, match="sweep failed"):
|
||||
mgr.discover_and_load()
|
||||
assert mgr._discovered is False, "failed sweep was cached as discovered"
|
||||
|
||||
# A later call (with discovery healthy again) must do the real scan.
|
||||
monkeypatch.undo()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
mgr.discover_and_load()
|
||||
assert mgr._discovered is True
|
||||
non_bundled = {
|
||||
n: p for n, p in mgr._plugins.items()
|
||||
if p.manifest.source != "bundled"
|
||||
}
|
||||
assert len(non_bundled) == 1
|
||||
|
||||
def test_discover_skips_dir_without_manifest(self, tmp_path, monkeypatch):
|
||||
"""Directories without plugin.yaml are silently skipped."""
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
|
||||
@@ -2360,6 +2360,42 @@ class TestNewEndpoints:
|
||||
resp = self.client.get("/api/cron/jobs/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# --- Automation Blueprints ---
|
||||
|
||||
def test_cron_blueprints_list(self):
|
||||
resp = self.client.get("/api/cron/blueprints")
|
||||
assert resp.status_code == 200
|
||||
blueprints = resp.json()["blueprints"]
|
||||
assert len(blueprints) >= 1
|
||||
first = blueprints[0]
|
||||
assert "fields" in first
|
||||
assert first["command"].startswith("/blueprint")
|
||||
assert first["appUrl"].startswith("hermes://")
|
||||
|
||||
def test_blueprint_instantiate_creates_job(self):
|
||||
resp = self.client.post(
|
||||
"/api/cron/blueprints/instantiate",
|
||||
json={"blueprint": "morning-brief", "values": {"time": "07:30", "deliver": "local"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
job = resp.json()
|
||||
assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \
|
||||
(job.get("schedule", {}) or {}).get("expr") == "30 7 * * *"
|
||||
|
||||
def test_blueprint_instantiate_unknown_404(self):
|
||||
resp = self.client.post(
|
||||
"/api/cron/blueprints/instantiate",
|
||||
json={"blueprint": "does-not-exist", "values": {}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_blueprint_instantiate_bad_value_422(self):
|
||||
resp = self.client.post(
|
||||
"/api/cron/blueprints/instantiate",
|
||||
json={"blueprint": "morning-brief", "values": {"time": "99:99"}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# --- Profiles ---
|
||||
|
||||
def test_profiles_list_includes_default(self):
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
pytest.importorskip("starlette.testclient")
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
test_client = TestClient(web_server.app)
|
||||
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
if previous_auth_required is None:
|
||||
try:
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
web_server.app.state.auth_required = previous_auth_required
|
||||
|
||||
|
||||
def test_fs_list_sorts_and_hides_noise(client, tmp_path):
|
||||
root = tmp_path / "project"
|
||||
root.mkdir()
|
||||
(root / "b.txt").write_text("b")
|
||||
(root / "a_dir").mkdir()
|
||||
(root / "a.txt").write_text("a")
|
||||
(root / "node_modules").mkdir()
|
||||
(root / ".git").mkdir()
|
||||
|
||||
response = client.get("/api/fs/list", params={"path": str(root)})
|
||||
|
||||
assert response.status_code == 200
|
||||
entries = response.json()["entries"]
|
||||
assert [entry["name"] for entry in entries] == ["a_dir", "a.txt", "b.txt"]
|
||||
assert entries[0] == {"name": "a_dir", "path": str(root / "a_dir"), "isDirectory": True}
|
||||
assert all(entry["name"] not in {".git", "node_modules"} for entry in entries)
|
||||
|
||||
|
||||
def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "rel").mkdir()
|
||||
(tmp_path / "rel" / "file.txt").write_text("ok")
|
||||
|
||||
response = client.get("/api/fs/list", params={"path": "rel"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["entries"] == [
|
||||
{"name": "file.txt", "path": str(tmp_path / "rel" / "file.txt"), "isDirectory": False}
|
||||
]
|
||||
|
||||
|
||||
def test_fs_list_missing_path_returns_structured_error(client, tmp_path):
|
||||
response = client.get("/api/fs/list", params={"path": str(tmp_path / "missing")})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"entries": [], "error": "ENOENT"}
|
||||
|
||||
|
||||
def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 32)
|
||||
monkeypatch.setattr(web_server, "_FS_TEXT_PREVIEW_MAX_BYTES", 5)
|
||||
target = tmp_path / "sample.py"
|
||||
target.write_text("print('hello')")
|
||||
|
||||
response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"binary": False,
|
||||
"byteSize": 14,
|
||||
"language": "python",
|
||||
"mimeType": "text/x-python",
|
||||
"path": str(target),
|
||||
"text": "print",
|
||||
"truncated": True,
|
||||
}
|
||||
|
||||
|
||||
def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 4)
|
||||
target = tmp_path / "large.txt"
|
||||
target.write_text("12345")
|
||||
|
||||
response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_fs_read_text_flags_binary(client, tmp_path):
|
||||
target = tmp_path / "blob.bin"
|
||||
target.write_bytes(b"hello\x00world")
|
||||
|
||||
response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["binary"] is True
|
||||
assert body["text"].startswith("hello")
|
||||
|
||||
|
||||
def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 16)
|
||||
target = tmp_path / "image.png"
|
||||
target.write_bytes(b"pngbytes")
|
||||
|
||||
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"dataUrl": "data:image/png;base64," + base64.b64encode(b"pngbytes").decode("ascii")}
|
||||
|
||||
|
||||
def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3)
|
||||
target = tmp_path / "image.png"
|
||||
target.write_bytes(b"1234")
|
||||
|
||||
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_fs_git_root_for_nested_file(client, tmp_path):
|
||||
(tmp_path / ".git").mkdir()
|
||||
nested = tmp_path / "pkg" / "mod"
|
||||
nested.mkdir(parents=True)
|
||||
target = nested / "file.py"
|
||||
target.write_text("x")
|
||||
|
||||
response = client.get("/api/fs/git-root", params={"path": str(target)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"root": str(tmp_path)}
|
||||
|
||||
|
||||
def test_fs_git_root_returns_null_outside_repo(client, tmp_path):
|
||||
response = client.get("/api/fs/git-root", params={"path": str(tmp_path)})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"root": None}
|
||||
|
||||
|
||||
def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": str(tmp_path)}})
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "env"))
|
||||
monkeypatch.setattr(web_server.Path, "cwd", lambda: tmp_path / "process")
|
||||
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "main")
|
||||
|
||||
response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"cwd": str(tmp_path), "branch": "main"}
|
||||
|
||||
|
||||
def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch):
|
||||
fallback = tmp_path / "backend"
|
||||
fallback.mkdir()
|
||||
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": "/client/missing"}})
|
||||
monkeypatch.setenv("TERMINAL_CWD", "/client/missing")
|
||||
monkeypatch.setattr(web_server.Path, "cwd", lambda: fallback)
|
||||
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "")
|
||||
|
||||
response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"cwd": str(fallback), "branch": ""}
|
||||
|
||||
|
||||
def test_fs_endpoints_require_auth(tmp_path):
|
||||
client = TestClient(web_server.app)
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
|
||||
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
|
||||
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
default_response = client.get("/api/fs/default-cwd")
|
||||
|
||||
assert list_response.status_code == 401
|
||||
assert read_response.status_code == 401
|
||||
assert default_response.status_code == 401
|
||||
@@ -92,6 +92,16 @@ class TestProfileScopedConfig:
|
||||
resp = client.get("/api/config/raw")
|
||||
assert "Io/Volcano" not in resp.json()["yaml"]
|
||||
|
||||
def test_config_raw_path_reflects_requested_profile(self, client, isolated_profiles):
|
||||
"""The Config page header shows /api/config/raw's ``path`` — it must
|
||||
point at the SWITCHED profile's config.yaml, not the dashboard's own
|
||||
(the stale-path bug reported after the profile unification launch)."""
|
||||
resp = client.get("/api/config/raw", params={"profile": "worker_beta"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["path"] == str(isolated_profiles["worker_beta"] / "config.yaml")
|
||||
resp = client.get("/api/config/raw")
|
||||
assert resp.json()["path"] == str(isolated_profiles["default"] / "config.yaml")
|
||||
|
||||
def test_unknown_profile_404(self, client, isolated_profiles):
|
||||
resp = client.get("/api/config", params={"profile": "ghost"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -140,6 +140,22 @@ class TestBuildWebUISkipsWhenFresh:
|
||||
assert kwargs["encoding"] == "utf-8"
|
||||
assert kwargs["errors"] == "replace"
|
||||
|
||||
def test_npm_install_sets_ci_to_suppress_postinstall_tty_output(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
|
||||
_run_npm_install_deterministic(
|
||||
"/usr/bin/npm",
|
||||
web_dir,
|
||||
env={"PYTHON": "/nix/store/python"},
|
||||
)
|
||||
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs["env"]["CI"] == "1"
|
||||
assert kwargs["env"]["PYTHON"] == "/nix/store/python"
|
||||
|
||||
def test_npm_install_uses_workspace_web_scope(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
# Real workspace checkout: the single lockfile lives at the root, so
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_task_runtime_exit_notifies_gateway_for_reconnect(monkeypatch):
|
||||
"""A post-ready discord.py websocket task crash must not leave the gateway split-brained.
|
||||
|
||||
Regression: producers stayed systemd-active while Discord stopped responding after
|
||||
a runtime ClientOSError/ConnectionResetError. The adapter must mark Discord as a
|
||||
retryable fatal platform error and notify the gateway supervisor so the existing
|
||||
reconnect watcher can replace the dead adapter.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
|
||||
adapter._running = True
|
||||
adapter._ready_event.set()
|
||||
adapter._notify_fatal_error = AsyncMock()
|
||||
|
||||
async def crash():
|
||||
raise ConnectionResetError("Cannot write to closing transport")
|
||||
|
||||
task = asyncio.create_task(crash())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter._handle_bot_task_done(task)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert adapter.has_fatal_error is True
|
||||
assert adapter.fatal_error_retryable is True
|
||||
assert adapter.fatal_error_code == "discord_gateway_task_exited"
|
||||
assert adapter.fatal_error_message is not None
|
||||
assert "Cannot write to closing transport" in adapter.fatal_error_message
|
||||
adapter._notify_fatal_error.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_bot_task_done_ignored_during_intentional_disconnect():
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="token"))
|
||||
adapter._running = True
|
||||
adapter._ready_event.set()
|
||||
adapter._disconnecting = True
|
||||
adapter._notify_fatal_error = AsyncMock()
|
||||
|
||||
async def stop_cleanly():
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(stop_cleanly())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter._handle_bot_task_done(task)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert adapter.has_fatal_error is False
|
||||
adapter._notify_fatal_error.assert_not_awaited()
|
||||
@@ -6096,6 +6096,24 @@ def test_make_agent_reads_nested_max_turns(monkeypatch):
|
||||
assert mock_agent.call_args.kwargs["max_iterations"] == 200
|
||||
|
||||
|
||||
def test_make_agent_waits_for_shared_mcp_discovery(monkeypatch):
|
||||
_setup_make_agent_mocks(monkeypatch, {})
|
||||
waited = []
|
||||
|
||||
from hermes_cli import mcp_startup
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_startup,
|
||||
"wait_for_mcp_discovery",
|
||||
lambda timeout=0.75: waited.append(timeout),
|
||||
)
|
||||
|
||||
with patch("run_agent.AIAgent"):
|
||||
server._make_agent("sid1", "key1")
|
||||
|
||||
assert waited == [0.75]
|
||||
|
||||
|
||||
def test_make_agent_nested_max_turns_takes_priority(monkeypatch):
|
||||
_setup_make_agent_mocks(
|
||||
monkeypatch, {"agent": {"max_turns": 500}, "max_turns": 100}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the blueprints layer (skill frontmatter <-> cron automation bridge).
|
||||
|
||||
A blueprint is a skill with a metadata.hermes.blueprint block. These verify parsing,
|
||||
the create-job bridge, and the export round-trip without touching the real
|
||||
cron store.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.blueprints import (
|
||||
BlueprintError,
|
||||
BlueprintSpec,
|
||||
create_blueprint_job,
|
||||
export_blueprint,
|
||||
parse_blueprint,
|
||||
blueprint_spec_for_installed,
|
||||
)
|
||||
|
||||
|
||||
BLUEPRINT_SKILL = """---
|
||||
name: morning-brief
|
||||
description: Summarize unread email and calendar every morning.
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [blueprint, email]
|
||||
blueprint:
|
||||
schedule: "0 8 * * *"
|
||||
deliver: telegram
|
||||
prompt: "Summarize my unread email and today's calendar."
|
||||
---
|
||||
|
||||
# Morning Brief
|
||||
|
||||
Every morning, gather unread email and the day's calendar and send a digest.
|
||||
"""
|
||||
|
||||
PLAIN_SKILL = """---
|
||||
name: not-a-blueprint
|
||||
description: Just a regular skill.
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [misc]
|
||||
---
|
||||
|
||||
# Not a blueprint
|
||||
"""
|
||||
|
||||
MALFORMED_BLUEPRINT = """---
|
||||
name: broken
|
||||
description: Blueprint with no schedule.
|
||||
metadata:
|
||||
hermes:
|
||||
blueprint:
|
||||
deliver: origin
|
||||
---
|
||||
|
||||
# Broken
|
||||
"""
|
||||
|
||||
|
||||
class TestParseBlueprint:
|
||||
def test_parses_full_blueprint(self):
|
||||
spec = parse_blueprint(BLUEPRINT_SKILL)
|
||||
assert spec is not None
|
||||
assert spec.skill_name == "morning-brief"
|
||||
assert spec.schedule == "0 8 * * *"
|
||||
assert spec.deliver == "telegram"
|
||||
assert spec.prompt is not None and spec.prompt.startswith("Summarize")
|
||||
|
||||
def test_plain_skill_is_not_a_blueprint(self):
|
||||
assert parse_blueprint(PLAIN_SKILL) is None
|
||||
|
||||
def test_no_frontmatter_is_not_a_blueprint(self):
|
||||
assert parse_blueprint("just some text, no frontmatter") is None
|
||||
|
||||
def test_missing_schedule_raises(self):
|
||||
with pytest.raises(BlueprintError):
|
||||
parse_blueprint(MALFORMED_BLUEPRINT)
|
||||
|
||||
def test_blueprint_not_mapping_raises(self):
|
||||
bad = "---\nname: x\nmetadata:\n hermes:\n blueprint: not-a-dict\n---\n\nbody"
|
||||
with pytest.raises(BlueprintError):
|
||||
parse_blueprint(bad)
|
||||
|
||||
def test_deliver_defaults_to_origin(self):
|
||||
skill = (
|
||||
"---\nname: r\ndescription: d\nmetadata:\n hermes:\n"
|
||||
' blueprint:\n schedule: "every 1h"\n---\n\nbody'
|
||||
)
|
||||
spec = parse_blueprint(skill)
|
||||
assert spec is not None
|
||||
assert spec.deliver == "origin"
|
||||
|
||||
|
||||
class TestBlueprintSpecForInstalled:
|
||||
def test_finds_and_parses_installed_blueprint(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
rec_dir = skills_dir / "productivity" / "morning-brief"
|
||||
rec_dir.mkdir(parents=True)
|
||||
(rec_dir / "SKILL.md").write_text(BLUEPRINT_SKILL, encoding="utf-8")
|
||||
|
||||
with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
|
||||
spec = blueprint_spec_for_installed("morning-brief")
|
||||
assert spec is not None
|
||||
assert spec.schedule == "0 8 * * *"
|
||||
|
||||
def test_missing_skill_returns_none(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
|
||||
assert blueprint_spec_for_installed("nope") is None
|
||||
|
||||
def test_plain_skill_returns_none(self, tmp_path):
|
||||
skills_dir = tmp_path / "skills"
|
||||
d = skills_dir / "misc" / "not-a-blueprint"
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(PLAIN_SKILL, encoding="utf-8")
|
||||
with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
|
||||
assert blueprint_spec_for_installed("not-a-blueprint") is None
|
||||
|
||||
|
||||
class TestCreateBlueprintJob:
|
||||
def test_bridges_to_create_job(self):
|
||||
spec = parse_blueprint(BLUEPRINT_SKILL)
|
||||
assert spec is not None
|
||||
captured = {}
|
||||
|
||||
def fake_create_job(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"id": "abc123", **kwargs}
|
||||
|
||||
with patch("cron.jobs.create_job", fake_create_job):
|
||||
job = create_blueprint_job(spec, origin={"platform": "telegram"})
|
||||
|
||||
assert captured["schedule"] == "0 8 * * *"
|
||||
assert captured["skills"] == ["morning-brief"]
|
||||
assert captured["deliver"] == "telegram"
|
||||
assert captured["prompt"].startswith("Summarize")
|
||||
assert job["id"] == "abc123"
|
||||
|
||||
|
||||
class TestExportBlueprint:
|
||||
def test_round_trips_job_to_skill_md(self):
|
||||
job = {
|
||||
"name": "My Morning Brief",
|
||||
"schedule_display": "0 8 * * *",
|
||||
"skills": ["morning-brief"],
|
||||
"deliver": "telegram",
|
||||
"prompt": "Summarize my unread email.",
|
||||
}
|
||||
md = export_blueprint(job, "# Morning Brief\n\nDoes the morning digest.")
|
||||
# The exported SKILL.md must itself parse back as a blueprint.
|
||||
spec = parse_blueprint(md)
|
||||
assert spec is not None
|
||||
assert spec.schedule == "0 8 * * *"
|
||||
assert spec.deliver == "telegram"
|
||||
# Name is sanitized to a valid skill identifier.
|
||||
assert spec.skill_name == "my-morning-brief"
|
||||
|
||||
def test_export_has_blueprint_tag(self):
|
||||
job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]}
|
||||
md = export_blueprint(job, "body")
|
||||
assert "blueprint" in md
|
||||
assert "automation" in md
|
||||
|
||||
def test_export_interval_job_without_display(self):
|
||||
# Regression: parse_schedule stores interval periods as "minutes" —
|
||||
# exporting a job with only the parsed schedule dict must round-trip
|
||||
# the real interval, not fall back to the daily default.
|
||||
job = {
|
||||
"name": "poller",
|
||||
"schedule": {"kind": "interval", "minutes": 30},
|
||||
"skills": ["poller"],
|
||||
}
|
||||
md = export_blueprint(job, "body")
|
||||
spec = parse_blueprint(md)
|
||||
assert spec is not None
|
||||
assert spec.schedule == "every 30m"
|
||||
|
||||
job["schedule"] = {"kind": "interval", "minutes": 120}
|
||||
spec = parse_blueprint(export_blueprint(job, "body"))
|
||||
assert spec is not None
|
||||
assert spec.schedule == "every 2h"
|
||||
@@ -381,9 +381,10 @@ class TestClawHubSource(unittest.TestCase):
|
||||
|
||||
mock_get.side_effect = side_effect
|
||||
|
||||
# Force the deadline to be in the past immediately.
|
||||
# Force the deadline to be in the past immediately. Budget only applies
|
||||
# to bounded browse walks (max_items > 0), not the index builder path.
|
||||
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
|
||||
results = self.src._load_catalog_index()
|
||||
results = self.src._load_catalog_index(max_items=10)
|
||||
|
||||
# Walk broke well before the 750-page cap.
|
||||
self.assertLess(page_calls["n"], 750)
|
||||
@@ -480,6 +481,23 @@ class TestClawHubCatalogWalkBounded(unittest.TestCase):
|
||||
# Partial (bounded) walk must not be cached.
|
||||
mock_write_cache.assert_not_called()
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_max_items_zero_ignores_wall_clock_budget(
|
||||
self, mock_get, _mock_read_cache, _mock_write_cache
|
||||
):
|
||||
"""Index builder path (max_items=0) must not truncate on the browse budget."""
|
||||
page_calls = {"n": 0}
|
||||
mock_get.side_effect = self._infinite_pages(page_calls)
|
||||
|
||||
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
|
||||
results = self.src._load_catalog_index(max_items=0)
|
||||
|
||||
# No budget -> walks until the 750-page safety cap, not ~14 pages in 12s.
|
||||
self.assertEqual(page_calls["n"], 750)
|
||||
self.assertEqual(len(results), 750)
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Regression: the keyless Parallel web default must survive a failed sweep.
|
||||
|
||||
``web_search`` / ``web_extract`` are documented to work out of the box with
|
||||
zero setup via the bundled keyless Parallel free-MCP backend. That guarantee
|
||||
only holds if the bundled ``plugins/web/*`` providers are registered in
|
||||
``agent.web_search_registry``. The dispatch triggers the general plugin sweep
|
||||
(:func:`hermes_cli.plugins._ensure_plugins_discovered`) to do that — but the
|
||||
sweep can finish without registering them (its exception swallowed as a
|
||||
warning, a packaged layout where it ran before the bundled tree was
|
||||
importable, or a stale empty-discovery cache). When that happened, *both*
|
||||
tools dead-ended on "No web {search,extract} provider configured" even though
|
||||
no setup should be needed.
|
||||
|
||||
These tests pin the invariant that :func:`tools.web_tools._ensure_web_plugins_loaded`
|
||||
guarantees the keyless default is registered regardless of the sweep's outcome,
|
||||
and that the direct-registration fallback honors an explicit ``plugins.disabled``
|
||||
entry. Real imports from the bundled plugin modules — no provider mocking.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.web_search_registry as reg
|
||||
import hermes_cli.plugins as plugins
|
||||
from tools import web_tools
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
reg._reset_for_tests()
|
||||
yield
|
||||
reg._reset_for_tests()
|
||||
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise RuntimeError("discovery boom")
|
||||
|
||||
|
||||
def test_keyless_default_registered_when_discovery_raises(monkeypatch):
|
||||
"""A swallowed discovery failure must not strand the keyless default."""
|
||||
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
|
||||
assert reg.get_provider("parallel") is None
|
||||
|
||||
web_tools._ensure_web_plugins_loaded()
|
||||
|
||||
parallel = reg.get_provider("parallel")
|
||||
assert parallel is not None, "keyless Parallel default not restored"
|
||||
# It is the universal keyless default precisely because it does both.
|
||||
assert parallel.supports_search()
|
||||
assert parallel.supports_extract()
|
||||
|
||||
|
||||
def test_fallback_registers_full_bundled_set(monkeypatch):
|
||||
"""The fix covers the whole bundled provider class, not just parallel."""
|
||||
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", _boom)
|
||||
|
||||
web_tools._ensure_web_plugins_loaded()
|
||||
|
||||
names = {p.name for p in reg.list_providers()}
|
||||
# Every bundled backend a user might have configured should be reachable
|
||||
# again, so an explicit ``web.extract_backend: firecrawl`` etc. resolves.
|
||||
for expected in ("parallel", "firecrawl", "tavily", "exa"):
|
||||
assert expected in names, f"{expected} missing after fallback"
|
||||
|
||||
|
||||
def test_fallback_honors_explicit_disable(monkeypatch):
|
||||
"""A backend the user turned off via plugins.disabled stays off."""
|
||||
monkeypatch.setattr(plugins, "_get_disabled_plugins", lambda: {"web-parallel"})
|
||||
|
||||
web_tools._register_bundled_web_providers_directly()
|
||||
|
||||
names = {p.name for p in reg.list_providers()}
|
||||
assert "parallel" not in names, "explicit disable was ignored"
|
||||
# Other bundled backends are unaffected by the parallel disable.
|
||||
assert "tavily" in names
|
||||
|
||||
|
||||
def test_fallback_is_noop_when_discovery_already_registered(monkeypatch):
|
||||
"""Healthy path: don't pay for the direct sweep when parallel is present."""
|
||||
# Pretend the general sweep already registered the keyless default.
|
||||
import importlib
|
||||
|
||||
class _Ctx:
|
||||
def register_web_search_provider(self, provider):
|
||||
reg.register_provider(provider)
|
||||
|
||||
importlib.import_module("plugins.web.parallel").register(_Ctx())
|
||||
monkeypatch.setattr(plugins, "_ensure_plugins_discovered", lambda *a, **k: None)
|
||||
|
||||
calls = {"n": 0}
|
||||
real = web_tools._register_bundled_web_providers_directly
|
||||
|
||||
def _spy():
|
||||
calls["n"] += 1
|
||||
real()
|
||||
|
||||
monkeypatch.setattr(web_tools, "_register_bundled_web_providers_directly", _spy)
|
||||
web_tools._ensure_web_plugins_loaded()
|
||||
|
||||
assert calls["n"] == 0, "direct-registration ran on the healthy path"
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Blueprints: shareable plain-language automations layered on skills + cron.
|
||||
|
||||
A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the
|
||||
agent loads) that additionally declares an automation schedule in its
|
||||
frontmatter:
|
||||
|
||||
metadata:
|
||||
hermes:
|
||||
blueprint:
|
||||
schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable
|
||||
deliver: origin # optional (default "origin")
|
||||
prompt: "..." # optional task instruction for the run
|
||||
no_agent: false # optional
|
||||
|
||||
Because a blueprint is just a skill, it flows through the ENTIRE existing
|
||||
skills-hub pipeline for free — search, inspect, quarantine, security scan,
|
||||
install, lock-file provenance, audit log, taps, the centralized index, and
|
||||
`hermes skills publish` for sharing. No new source type, no new store, no new
|
||||
transport. This module is the thin bridge between that skill metadata and the
|
||||
existing cron `create_job()` API:
|
||||
|
||||
* ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None
|
||||
* ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None
|
||||
* ``create_blueprint_job(spec, ...)`` -> the created cron job dict
|
||||
* ``export_blueprint(job, body)`` -> a shareable SKILL.md string
|
||||
|
||||
The dev guide's "Extend, Don't Duplicate" rule is the whole design: the blueprint
|
||||
is a skill, the schedule is a cron job, sharing is the existing publish/tap/
|
||||
index path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"BlueprintSpec",
|
||||
"parse_blueprint",
|
||||
"blueprint_spec_for_installed",
|
||||
"blueprint_to_job_spec",
|
||||
"create_blueprint_job",
|
||||
"register_blueprint_suggestion",
|
||||
"export_blueprint",
|
||||
"BlueprintError",
|
||||
]
|
||||
|
||||
|
||||
class BlueprintError(ValueError):
|
||||
"""Raised when a blueprint block is present but malformed."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlueprintSpec:
|
||||
"""Parsed ``metadata.hermes.blueprint`` automation spec for a skill."""
|
||||
|
||||
skill_name: str
|
||||
schedule: str
|
||||
deliver: str = "origin"
|
||||
prompt: Optional[str] = None
|
||||
no_agent: bool = False
|
||||
model: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
enabled_toolsets: Optional[List[str]] = None
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the parsed YAML frontmatter mapping, or None if absent/invalid."""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
stripped = text.lstrip()
|
||||
if not stripped.startswith("---"):
|
||||
return None
|
||||
# Find the closing fence after the opening one.
|
||||
after_open = stripped[3:]
|
||||
end = after_open.find("\n---")
|
||||
if end == -1:
|
||||
return None
|
||||
fm_text = after_open[:end]
|
||||
try:
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(fm_text)
|
||||
except Exception as e: # pragma: no cover - malformed YAML
|
||||
logger.debug("blueprint: frontmatter YAML parse failed: %s", e)
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
|
||||
"""Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint.
|
||||
|
||||
A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing
|
||||
a non-empty ``schedule``. Raises BlueprintError if the block exists but is
|
||||
structurally invalid (so a typo surfaces instead of silently no-op'ing).
|
||||
"""
|
||||
fm = _split_frontmatter(skill_md_text)
|
||||
if not fm:
|
||||
return None
|
||||
|
||||
name = str(fm.get("name", "")).strip()
|
||||
|
||||
meta = fm.get("metadata")
|
||||
hermes = meta.get("hermes") if isinstance(meta, dict) else None
|
||||
blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None
|
||||
if blueprint is None:
|
||||
return None
|
||||
if not isinstance(blueprint, dict):
|
||||
raise BlueprintError("metadata.hermes.blueprint must be a mapping")
|
||||
|
||||
schedule = str(blueprint.get("schedule", "")).strip()
|
||||
if not schedule:
|
||||
raise BlueprintError("blueprint.schedule is required and must be non-empty")
|
||||
|
||||
deliver = str(blueprint.get("deliver", "origin")).strip() or "origin"
|
||||
prompt = blueprint.get("prompt")
|
||||
if prompt is not None:
|
||||
prompt = str(prompt)
|
||||
no_agent = bool(blueprint.get("no_agent", False))
|
||||
model = blueprint.get("model")
|
||||
provider = blueprint.get("provider")
|
||||
toolsets = blueprint.get("enabled_toolsets")
|
||||
if toolsets is not None and not isinstance(toolsets, list):
|
||||
raise BlueprintError("blueprint.enabled_toolsets must be a list when present")
|
||||
|
||||
return BlueprintSpec(
|
||||
skill_name=name,
|
||||
schedule=schedule,
|
||||
deliver=deliver,
|
||||
prompt=prompt,
|
||||
no_agent=no_agent,
|
||||
model=str(model).strip() if model else None,
|
||||
provider=str(provider).strip() if provider else None,
|
||||
enabled_toolsets=[str(t) for t in toolsets] if toolsets else None,
|
||||
raw=blueprint,
|
||||
)
|
||||
|
||||
|
||||
def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]:
|
||||
"""Locate an installed skill's SKILL.md and parse its blueprint block.
|
||||
|
||||
Searches the standard skills tree for ``<skill_name>/SKILL.md``. Returns
|
||||
None if the skill isn't found or isn't a blueprint.
|
||||
"""
|
||||
try:
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
except Exception: # pragma: no cover - import guard
|
||||
return None
|
||||
|
||||
base = Path(SKILLS_DIR)
|
||||
# Skills live at skills/<category>/<name>/SKILL.md or skills/<name>/SKILL.md.
|
||||
candidates = list(base.glob(f"**/{skill_name}/SKILL.md"))
|
||||
for path in candidates:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
spec = parse_blueprint(text)
|
||||
if spec is not None:
|
||||
# Prefer the frontmatter name, fall back to the directory name.
|
||||
if not spec.skill_name:
|
||||
spec.skill_name = skill_name
|
||||
return spec
|
||||
return None
|
||||
|
||||
|
||||
def blueprint_to_job_spec(
|
||||
spec: BlueprintSpec,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec.
|
||||
|
||||
This is the single source of truth for translating a blueprint into a job.
|
||||
Both the direct ``create_blueprint_job`` path and the suggestion path
|
||||
(``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and
|
||||
a blueprint accepted from a suggestion produce an identical job.
|
||||
"""
|
||||
return {
|
||||
"prompt": spec.prompt,
|
||||
"schedule": spec.schedule,
|
||||
"name": name or f"blueprint:{spec.skill_name}",
|
||||
"deliver": spec.deliver,
|
||||
"skills": [spec.skill_name] if spec.skill_name else None,
|
||||
"model": spec.model,
|
||||
"provider": spec.provider,
|
||||
"enabled_toolsets": spec.enabled_toolsets,
|
||||
"no_agent": spec.no_agent,
|
||||
}
|
||||
|
||||
|
||||
def create_blueprint_job(
|
||||
spec: BlueprintSpec,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create the cron job described by a BlueprintSpec via the existing cron API.
|
||||
|
||||
The blueprint's skill is loaded before the run (cron ``skills=[name]``); the
|
||||
optional ``prompt`` becomes the task instruction. Delivery, model, and
|
||||
toolsets carry through. Returns the created job dict.
|
||||
"""
|
||||
from cron.jobs import create_job
|
||||
|
||||
job_spec = blueprint_to_job_spec(spec, name=name)
|
||||
if origin is not None:
|
||||
job_spec["origin"] = origin
|
||||
return create_job(**job_spec)
|
||||
|
||||
|
||||
def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]:
|
||||
"""Turn an installed blueprint into a pending Suggested Cron Job.
|
||||
|
||||
Blueprints are source ``blueprint`` of the unified suggestion surface: installing
|
||||
a skill that carries a ``blueprint:`` block does NOT auto-schedule it — it
|
||||
registers a suggestion the user accepts (or dismisses) like any other.
|
||||
Returns the suggestion record, or None if it was skipped (already
|
||||
seen/dismissed, backlog full, etc.).
|
||||
"""
|
||||
if not spec.skill_name:
|
||||
return None
|
||||
try:
|
||||
from cron.suggestions import add_suggestion
|
||||
except Exception: # pragma: no cover - import guard
|
||||
return None
|
||||
|
||||
return add_suggestion(
|
||||
title=f"Schedule '{spec.skill_name}'",
|
||||
description=(
|
||||
f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}"
|
||||
+ (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "")
|
||||
+ "."
|
||||
),
|
||||
source="blueprint",
|
||||
job_spec=blueprint_to_job_spec(spec),
|
||||
dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}",
|
||||
)
|
||||
|
||||
|
||||
def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str:
|
||||
"""Render a shareable blueprint SKILL.md from an existing cron job dict.
|
||||
|
||||
The inverse of ``create_blueprint_job``: take a cron job a user already built
|
||||
and emit a SKILL.md (with a ``metadata.hermes.blueprint`` block) they can hand
|
||||
to ``hermes skills publish`` to share. ``body`` is the plain-language
|
||||
description / instructions that become the SKILL.md body.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
name = blueprint_name or job.get("name") or "shared-blueprint"
|
||||
# Sanitize to a valid skill identifier.
|
||||
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower())
|
||||
name = name.strip("-_") or "shared-blueprint"
|
||||
|
||||
schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule"))
|
||||
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
|
||||
|
||||
blueprint_block: Dict[str, Any] = {"schedule": schedule}
|
||||
deliver = job.get("deliver")
|
||||
if deliver and deliver != "origin":
|
||||
blueprint_block["deliver"] = deliver
|
||||
if job.get("prompt"):
|
||||
blueprint_block["prompt"] = job["prompt"]
|
||||
if job.get("no_agent"):
|
||||
blueprint_block["no_agent"] = True
|
||||
if job.get("model"):
|
||||
blueprint_block["model"] = job["model"]
|
||||
if job.get("provider"):
|
||||
blueprint_block["provider"] = job["provider"]
|
||||
if job.get("enabled_toolsets"):
|
||||
blueprint_block["enabled_toolsets"] = job["enabled_toolsets"]
|
||||
|
||||
description = (
|
||||
(body.strip().splitlines() or ["Shared automation blueprint."])[0][:200]
|
||||
if body.strip()
|
||||
else "Shared automation blueprint."
|
||||
)
|
||||
|
||||
frontmatter = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"metadata": {
|
||||
"hermes": {
|
||||
"tags": ["blueprint", "automation"],
|
||||
"blueprint": blueprint_block,
|
||||
}
|
||||
},
|
||||
}
|
||||
fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip()
|
||||
body_text = body.strip() or f"# {name}\n\nShared automation blueprint."
|
||||
return f"---\n{fm_yaml}\n---\n\n{body_text}\n"
|
||||
|
||||
|
||||
def _schedule_to_string(schedule: Any) -> str:
|
||||
"""Best-effort render of a parsed schedule dict back to a string."""
|
||||
if isinstance(schedule, str):
|
||||
return schedule
|
||||
if isinstance(schedule, dict):
|
||||
kind = schedule.get("kind")
|
||||
if kind == "cron" and schedule.get("expr"):
|
||||
return str(schedule["expr"])
|
||||
if kind == "interval":
|
||||
# parse_schedule stores interval periods as "minutes"; tolerate a
|
||||
# legacy/foreign "seconds" form too.
|
||||
if schedule.get("minutes"):
|
||||
mins = int(schedule["minutes"])
|
||||
if mins % 60 == 0:
|
||||
return f"every {mins // 60}h"
|
||||
return f"every {mins}m"
|
||||
if schedule.get("seconds"):
|
||||
secs = int(schedule["seconds"])
|
||||
if secs % 3600 == 0:
|
||||
return f"every {secs // 3600}h"
|
||||
if secs % 60 == 0:
|
||||
return f"every {secs // 60}m"
|
||||
return f"every {secs}s"
|
||||
return "0 9 * * *" # safe daily fallback
|
||||
+10
-2
@@ -2279,12 +2279,20 @@ class ClawHubSource(SkillSource):
|
||||
# terminates well before this on `nextCursor` going None — the cap is
|
||||
# a safety rail against an infinite-cursor loop.
|
||||
max_pages = 750
|
||||
deadline = time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
|
||||
# Wall-clock budget is for interactive browse (max_items > 0) only.
|
||||
# The offline index builder passes max_items=0 and must walk the full
|
||||
# catalog — a 12s cap there ships ~3k skills and trips the deploy
|
||||
# health floor (20k).
|
||||
deadline = (
|
||||
time.monotonic() + self.CATALOG_WALK_BUDGET_SECONDS
|
||||
if max_items > 0
|
||||
else None
|
||||
)
|
||||
hit_deadline = False
|
||||
hit_max_items = False
|
||||
|
||||
for _ in range(max_pages):
|
||||
if time.monotonic() > deadline:
|
||||
if deadline is not None and time.monotonic() > deadline:
|
||||
hit_deadline = True
|
||||
break
|
||||
params: Dict[str, Any] = {"limit": 200}
|
||||
|
||||
@@ -810,6 +810,17 @@ def _ensure_web_plugins_loaded() -> None:
|
||||
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
|
||||
the underlying discovery call is idempotent and cheap on subsequent
|
||||
invocations.
|
||||
|
||||
Triggering discovery is necessary but not *sufficient*: the sweep can
|
||||
finish without registering the bundled web providers (its exception
|
||||
swallowed below as a warning, a packaged layout where discovery ran before
|
||||
the bundled tree was importable, or a stale empty-discovery cache). When
|
||||
that happens the registry is empty and *both* web_search and web_extract
|
||||
dead-end on "No web {search,extract} provider configured" — even though the
|
||||
keyless Parallel default is supposed to work with zero setup. So after
|
||||
discovery we verify the keyless default landed and, if not, register the
|
||||
bundled providers directly (see
|
||||
:func:`_register_bundled_web_providers_directly`).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
@@ -822,6 +833,87 @@ def _ensure_web_plugins_loaded() -> None:
|
||||
# clue in normal logs about the real cause.
|
||||
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
|
||||
|
||||
# Belt-and-suspenders: guarantee the keyless Parallel default (the
|
||||
# documented zero-setup backend for both web_search and web_extract) is
|
||||
# actually registered. The lookup is a cheap dict hit on the healthy path
|
||||
# (discovery already registered it → no-op); only an empty registry pays
|
||||
# for the direct-registration sweep.
|
||||
try:
|
||||
from agent.web_search_registry import get_provider
|
||||
|
||||
if get_provider("parallel") is None:
|
||||
_register_bundled_web_providers_directly()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Bundled web provider fallback check failed: %s", exc)
|
||||
|
||||
|
||||
def _register_bundled_web_providers_directly() -> None:
|
||||
"""Register the repo's bundled web providers without the plugin manager.
|
||||
|
||||
The normal path is the general plugin sweep
|
||||
(:func:`hermes_cli.plugins._ensure_plugins_discovered`), which auto-loads
|
||||
every ``plugins/web/<name>`` backend (they are ``kind: backend``). This
|
||||
fallback exists for the runtimes where that sweep does not leave the web
|
||||
registry populated — so the keyless Parallel default (and any bundled
|
||||
backend the user explicitly configured) keeps working instead of
|
||||
surfacing a misleading "No web provider configured" error.
|
||||
|
||||
Imports each bundled ``plugins/web/<name>`` package and calls its
|
||||
``register()`` directly against :mod:`agent.web_search_registry`. Idempotent
|
||||
(re-register overwrites) and honors an explicit ``plugins.disabled`` entry
|
||||
so a backend the user turned off stays off.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import (
|
||||
_get_disabled_plugins,
|
||||
get_bundled_plugins_dir,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("Bundled web provider fallback unavailable: %s", exc)
|
||||
return
|
||||
|
||||
web_dir = get_bundled_plugins_dir() / "web"
|
||||
if not web_dir.is_dir():
|
||||
return
|
||||
|
||||
disabled = _get_disabled_plugins()
|
||||
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from agent.web_search_registry import register_provider
|
||||
|
||||
class _DirectRegistrationCtx:
|
||||
"""Minimal plugin ctx exposing only web-provider registration."""
|
||||
|
||||
def register_web_search_provider(self, provider) -> None:
|
||||
if isinstance(provider, WebSearchProvider):
|
||||
register_provider(provider)
|
||||
|
||||
ctx = _DirectRegistrationCtx()
|
||||
import importlib
|
||||
|
||||
for child in sorted(web_dir.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
if not (child / "plugin.yaml").exists() and not (child / "plugin.yml").exists():
|
||||
continue
|
||||
# Respect an explicit disable — match discover_and_load's key/name
|
||||
# check (key ``web/<dir>``; manifest name ``web-<dir-with-dashes>``).
|
||||
if (
|
||||
f"web/{child.name}" in disabled
|
||||
or f"web-{child.name.replace('_', '-')}" in disabled
|
||||
):
|
||||
continue
|
||||
try:
|
||||
module = importlib.import_module(f"plugins.web.{child.name}")
|
||||
register_fn = getattr(module, "register", None)
|
||||
if callable(register_fn):
|
||||
register_fn(ctx)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(
|
||||
"Direct registration of bundled web provider '%s' failed: %s",
|
||||
child.name, exc,
|
||||
)
|
||||
|
||||
|
||||
def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
"""
|
||||
|
||||
+11
-5
@@ -3072,11 +3072,17 @@ def _make_agent(
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
# MCP tool discovery runs in a background daemon thread at startup so a
|
||||
# dead server can't freeze the shell (see tui_gateway/entry.py). The agent
|
||||
# snapshots its tool list once here and never re-reads it, so briefly wait
|
||||
# for in-flight discovery to land before building — bounded, so a slow/dead
|
||||
# server still can't block. No-op once discovery has finished (every build
|
||||
# after the first during a slow startup).
|
||||
# dead server can't freeze the shell. The agent snapshots its tool list
|
||||
# once here and never re-reads it, so briefly wait for in-flight discovery
|
||||
# to land before building — bounded, so a slow/dead server still can't
|
||||
# block. Dashboard /api/ws uses hermes_cli.mcp_startup; TUI stdio keeps
|
||||
# its existing tui_gateway.entry-owned thread.
|
||||
try:
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tui_gateway.entry import wait_for_mcp_discovery
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8",
|
||||
"@typescript-eslint/parser": "^8",
|
||||
|
||||
@@ -80,7 +80,7 @@ const asWireText = (raw: unknown): string | null => {
|
||||
}
|
||||
|
||||
if (raw instanceof ArrayBuffer || ArrayBuffer.isView(raw)) {
|
||||
return _wireDecoder.decode(raw as ArrayBufferLike)
|
||||
return _wireDecoder.decode(raw as any as ArrayBuffer)
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Clock, Wand2 } from "lucide-react";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
interface AutomationBlueprintsProps {
|
||||
profile: string;
|
||||
/** Called after a blueprint is instantiated so the parent can refresh its job list. */
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
/** Initial form values for a blueprint = each field's default (or ""). */
|
||||
function initialValues(blueprint: AutomationBlueprint): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const f of blueprint.fields) out[f.name] = f.default ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
function FieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: AutomationBlueprintField;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
if (field.type === "enum" || field.type === "weekdays") {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v)}>
|
||||
{field.options.map((opt) => (
|
||||
<SelectOption key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
if (field.type === "time") {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// text
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={field.help || field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BlueprintCard({
|
||||
blueprint,
|
||||
profile,
|
||||
showToast,
|
||||
onCreated,
|
||||
}: {
|
||||
blueprint: AutomationBlueprint;
|
||||
profile: string;
|
||||
showToast: (message: string, type: "error" | "success") => void;
|
||||
onCreated?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>(() => initialValues(blueprint));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile);
|
||||
const when = job.schedule_display ? ` — ${job.schedule_display}` : "";
|
||||
showToast(`${blueprint.title} scheduled${when}`, "success");
|
||||
setOpen(false);
|
||||
setValues(initialValues(blueprint));
|
||||
onCreated?.();
|
||||
} catch (e) {
|
||||
// 422 from the API carries the slot-level validation message.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg.replace(/^\d+:\s*/, ""));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [blueprint, values, profile, showToast, onCreated]);
|
||||
|
||||
return (
|
||||
<Card className={cn("overflow-hidden", themedBody)}>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="h-4 w-4 shrink-0 opacity-70" />
|
||||
<span className="font-medium">{blueprint.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{blueprint.tags.map((t) => (
|
||||
<Badge key={t} tone="secondary">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
ghost={open}
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{open ? "Cancel" : "Set up"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{blueprint.fields.map((f) => (
|
||||
<div key={f.name} className="space-y-1">
|
||||
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
|
||||
<FieldInput
|
||||
field={f}
|
||||
value={values[f.name] ?? ""}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
|
||||
/>
|
||||
{f.help && f.type !== "text" ? (
|
||||
<p className="text-xs opacity-60">{f.help}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{error ? (
|
||||
<p className="text-sm text-red-500" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
|
||||
Schedule it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automation Blueprints gallery — the form-where-there's-a-screen surface. Each blueprint
|
||||
* card expands into an inline form (one field per typed slot); submitting POSTs
|
||||
* to /api/cron/blueprints/instantiate which fills the blueprint and creates the job
|
||||
* via the same create_job path as everything else.
|
||||
*/
|
||||
export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
|
||||
const { toast, showToast } = useToast();
|
||||
const [blueprints, setBlueprints] = useState<AutomationBlueprint[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAutomationBlueprints()
|
||||
.then((r) => {
|
||||
if (!cancelled) setBlueprints(r.blueprints);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loadError) {
|
||||
return <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
|
||||
}
|
||||
if (blueprints === null) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 opacity-70">
|
||||
<Spinner className="h-4 w-4" /> Loading blueprints…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (blueprints.length === 0) {
|
||||
return <p className="opacity-70">No automation blueprints available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} />
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{blueprints.map((r) => (
|
||||
<BlueprintCard
|
||||
key={r.key}
|
||||
blueprint={r}
|
||||
profile={profile}
|
||||
showToast={showToast}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutomationBlueprints;
|
||||
+37
-1
@@ -432,7 +432,7 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
|
||||
getConfigRaw: () => fetchJSON<{ yaml: string; path?: string }>("/api/config/raw"),
|
||||
saveConfigRaw: (yaml_text: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
||||
method: "PUT",
|
||||
@@ -497,6 +497,19 @@ export const api = {
|
||||
deleteCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
|
||||
|
||||
// Automation Blueprints — parameterized automation blueprints
|
||||
getAutomationBlueprints: () =>
|
||||
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
|
||||
instantiateAutomationBlueprint: (
|
||||
body: { blueprint: string; values: Record<string, string> },
|
||||
profile = "default",
|
||||
) =>
|
||||
fetchJSON<CronJob>(`/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
// Profiles
|
||||
getProfiles: () =>
|
||||
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
|
||||
@@ -1825,6 +1838,29 @@ export interface CronDeliveryTarget {
|
||||
home_env_var: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationBlueprintField {
|
||||
name: string;
|
||||
type: "time" | "enum" | "text" | "weekdays";
|
||||
label: string;
|
||||
default: string | null;
|
||||
options: string[];
|
||||
optional: boolean;
|
||||
/** When false, options are suggestions — any value is accepted. */
|
||||
strict?: boolean;
|
||||
help: string;
|
||||
}
|
||||
|
||||
export interface AutomationBlueprint {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
fields: AutomationBlueprintField[];
|
||||
command: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -177,9 +177,19 @@ export default function ConfigPage() {
|
||||
.getDefaults()
|
||||
.then(setDefaults)
|
||||
.catch(() => {});
|
||||
// getConfigRaw is profile-scoped (fetchJSON appends ?profile=), so its
|
||||
// `path` reflects the switched profile's config.yaml. /api/status's
|
||||
// config_path is machine-global (the dashboard's own profile) — wrong
|
||||
// header under the global profile switcher, so it's only a fallback.
|
||||
api
|
||||
.getConfigRaw()
|
||||
.then((resp) => {
|
||||
if (resp.path) setConfigPath(resp.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
api
|
||||
.getStatus()
|
||||
.then((resp) => setConfigPath(resp.config_path))
|
||||
.then((resp) => setConfigPath((prev) => prev ?? resp.config_path))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
import { Segmented } from "@nous-research/ui/ui/components/segmented";
|
||||
import { AutomationBlueprints } from "@/components/AutomationBlueprints";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
@@ -176,6 +178,7 @@ export default function CronPage() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState("all");
|
||||
const [view, setView] = useState<"jobs" | "blueprints">("jobs");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t, locale } = useI18n();
|
||||
@@ -507,6 +510,23 @@ export default function CronPage() {
|
||||
<PluginSlot name="cron:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
<Segmented
|
||||
value={view}
|
||||
onChange={(v) => setView(v as "jobs" | "blueprints")}
|
||||
options={[
|
||||
{ value: "jobs", label: "Jobs" },
|
||||
{ value: "blueprints", label: "Blueprints" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{view === "blueprints" && (
|
||||
<AutomationBlueprints
|
||||
profile={selectedProfile === "all" ? "default" : selectedProfile}
|
||||
onCreated={loadJobs}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={jobDelete.isOpen}
|
||||
onCancel={jobDelete.cancel}
|
||||
@@ -746,6 +766,7 @@ export default function CronPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "jobs" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
@@ -888,6 +909,7 @@ export default function CronPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PluginSlot name="cron:bottom" />
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,11 @@ metadata:
|
||||
description: "What this setting controls"
|
||||
default: "sensible-default"
|
||||
prompt: "Display prompt for setup"
|
||||
blueprint: # Optional — marks this skill a runnable automation
|
||||
schedule: "0 9 * * *" # cron expr / "every 2h" / ISO timestamp
|
||||
deliver: origin # optional (default origin)
|
||||
prompt: "Task instruction for each run" # optional
|
||||
no_agent: false # optional
|
||||
required_environment_variables: # Optional — env vars the skill needs
|
||||
- name: MY_API_KEY
|
||||
prompt: "Enter your API key"
|
||||
@@ -334,6 +339,64 @@ If your skill is official and useful but not universally needed (e.g., a paid se
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`.
|
||||
|
||||
## Blueprints: skills that are also automations
|
||||
|
||||
A **blueprint** is an ordinary skill that additionally declares a schedule in its frontmatter. Add a `metadata.hermes.blueprint` block and the skill becomes a shareable, runnable automation:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [blueprint, email]
|
||||
blueprint:
|
||||
schedule: "0 8 * * *" # presence of `blueprint:` marks it runnable
|
||||
deliver: telegram # optional (default: origin)
|
||||
prompt: "Summarize my unread email and today's calendar." # optional
|
||||
no_agent: false # optional
|
||||
```
|
||||
|
||||
Because a blueprint **is** a skill, it flows through the entire skills pipeline unchanged — search, inspect, install, security scan, provenance, taps, the centralized index, and `hermes skills publish` for sharing. Nothing new to learn.
|
||||
|
||||
**Installing a blueprint.** When you install a skill that carries a `blueprint:` block, Hermes registers it as a **suggested cron job** rather than scheduling it. Scheduling is **opt-in** — installing never silently creates a recurring job. You review and accept it via `/suggestions`:
|
||||
|
||||
```bash
|
||||
hermes skills install owner/morning-brief
|
||||
# → Blueprint: 'morning-brief' is an automation (schedule 0 8 * * *).
|
||||
# Added to your suggestions — run /suggestions to schedule or dismiss it.
|
||||
|
||||
# then, in a session:
|
||||
/suggestions # lists pending suggestions, numbered
|
||||
/suggestions accept 1 # creates the cron job
|
||||
/suggestions dismiss 1 # never offer it again
|
||||
```
|
||||
|
||||
Blueprints are one **source** of the unified Suggested Cron Jobs surface — the same place curated starter automations and (later) usage-pattern and integration suggestions appear. See [Suggested Cron Jobs](#suggested-cron-jobs) below.
|
||||
|
||||
**Sharing an automation you built.** A blueprint loaded by a cron job (`hermes cron create --skill <name> ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else.
|
||||
|
||||
The blueprint layer adds no new object type, store, or transport — the blueprint is a skill, the schedule is a cron job, and sharing is the existing publish/tap/index path.
|
||||
|
||||
## Suggested Cron Jobs
|
||||
|
||||
Hermes can *propose* automations and let you accept them with one tap, instead of making you assemble cron jobs by hand. Every proposal flows through one surface — the `/suggestions` command — regardless of where it came from:
|
||||
|
||||
| Source | Trigger |
|
||||
|--------|---------|
|
||||
| `catalog` | Curated starter automations (`/suggestions catalog`) — daily briefing, important-mail monitor, weekly review, workday-start reminder |
|
||||
| `blueprint` | You installed a skill carrying a `blueprint:` block |
|
||||
| `usage` | The background review noticed a recurring ask a schedule would serve |
|
||||
| `integration` | You connected an account (Gmail, GitHub, ...) and the obvious automations are offered |
|
||||
|
||||
```bash
|
||||
/suggestions # list pending
|
||||
/suggestions accept N # schedule suggestion N (creates the cron job)
|
||||
/suggestions dismiss N # dismiss it — latched, never re-offered
|
||||
/suggestions catalog # add the curated starter automations
|
||||
```
|
||||
|
||||
Accepting a suggestion calls the same `cron.jobs.create_job` the `cronjob` tool uses — there is no second job engine. Suggestions **never** auto-create jobs; acceptance is always explicit. Dismissed suggestions latch by a stable key so the same proposal is never re-offered. The pending list is capped so it never becomes a nag wall.
|
||||
|
||||
The **important-mail monitor** catalog entry is the poll→classify→surface pattern: it scores inbox items with a cheap classifier model (`auxiliary.monitor` in `config.yaml`) and delivers only the ones above an urgency threshold, staying silent otherwise.
|
||||
|
||||
## Publishing Skills
|
||||
|
||||
### To the Skills Hub
|
||||
|
||||
+7
-5
@@ -1,14 +1,16 @@
|
||||
---
|
||||
sidebar_position: 15
|
||||
title: "Automation Templates"
|
||||
description: "Ready-to-use automation recipes — scheduled tasks, GitHub event triggers, API webhooks, and multi-skill workflows"
|
||||
title: "Automation Blueprints"
|
||||
description: "Ready-to-use automation blueprints — scheduled tasks, GitHub event triggers, API webhooks, and multi-skill workflows"
|
||||
---
|
||||
|
||||
# Automation Templates
|
||||
# Automation Blueprints
|
||||
|
||||
Copy-paste recipes for common automation patterns. Each template uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
|
||||
Copy-paste blueprints for common automation patterns. Each blueprint uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
|
||||
|
||||
Every template works with **any model** — not locked to a single provider.
|
||||
Every blueprint works with **any model** — not locked to a single provider.
|
||||
|
||||
For parameterized blueprints with forms instead of cron syntax, see the [Automation Blueprints Catalog](/reference/automation-blueprints-catalog).
|
||||
|
||||
:::tip Three Trigger Types
|
||||
| Trigger | How | Tool |
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
title: "Automation Blueprints Catalog"
|
||||
description: "Ready-to-run automation blueprints — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app."
|
||||
---
|
||||
|
||||
import AutomationBlueprintsCatalog from '@site/src/components/AutomationBlueprintsCatalog';
|
||||
|
||||
# Automation Blueprints
|
||||
|
||||
Automation Blueprints are ready-to-run automations. Pick one, fill in a couple
|
||||
of fields, and Hermes schedules it as a cron job — no cron syntax required.
|
||||
|
||||
Every blueprint works from **every surface**:
|
||||
|
||||
- **Dashboard / desktop app** — open the Cron page, switch to the **Blueprints**
|
||||
tab, fill the form, and click *Schedule it*.
|
||||
- **CLI, TUI, and messengers** — type `/blueprint <name>` (e.g.
|
||||
`/blueprint morning-brief`) and Hermes asks you for what it needs, one
|
||||
question at a time, then schedules it. The name match is forgiving — a
|
||||
prefix or near-spelling resolves. Power users can skip the questions by
|
||||
passing values inline: `/blueprint morning-brief time=08:00`.
|
||||
- **Desktop app** — click **Send to App** on any blueprint and it opens with the
|
||||
command pre-loaded in your composer.
|
||||
|
||||
Blueprints never schedule anything silently — you always confirm before the job
|
||||
is created. Manage created jobs anytime with `/cron`.
|
||||
|
||||
<AutomationBlueprintsCatalog />
|
||||
|
||||
## Writing your own
|
||||
|
||||
A blueprint is just a skill with a `metadata.hermes.blueprint` block in its
|
||||
`SKILL.md` frontmatter. See
|
||||
[Creating Skills → Automation Blueprints](../developer-guide/creating-skills.md) for the
|
||||
slot schema and how to publish one.
|
||||
+7
-5
@@ -1,14 +1,16 @@
|
||||
---
|
||||
sidebar_position: 15
|
||||
title: "自动化模板"
|
||||
description: "开箱即用的自动化配方——定时任务、GitHub 事件触发、API webhook 及多技能工作流"
|
||||
title: "自动化蓝图"
|
||||
description: "开箱即用的自动化蓝图——定时任务、GitHub 事件触发、API webhook 及多技能工作流"
|
||||
---
|
||||
|
||||
# 自动化模板
|
||||
# 自动化蓝图
|
||||
|
||||
常见自动化模式的复制粘贴配方。每个模板使用 Hermes 内置的 [cron 调度器](/user-guide/features/cron) 实现基于时间的触发,使用 [webhook 平台](/user-guide/messaging/webhooks) 实现事件驱动触发。
|
||||
常见自动化模式的复制粘贴蓝图。每个蓝图使用 Hermes 内置的 [cron 调度器](/user-guide/features/cron) 实现基于时间的触发,使用 [webhook 平台](/user-guide/messaging/webhooks) 实现事件驱动触发。
|
||||
|
||||
所有模板适用于**任意模型**——不绑定单一提供商。
|
||||
所有蓝图适用于**任意模型**——不绑定单一提供商。
|
||||
|
||||
如需带表单的参数化蓝图(无需手写 cron 语法),请参阅[自动化蓝图目录](/reference/automation-blueprints-catalog)。
|
||||
|
||||
:::tip 三种触发类型
|
||||
| 触发方式 | 方式 | 工具 |
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the Automation Blueprints catalog JSON for the docs site.
|
||||
|
||||
Mirrors ``extract-skills.py``: imports the single-source-of-truth blueprint
|
||||
definitions from ``cron/blueprint_catalog.py`` and emits a flat JSON array the
|
||||
docs page renders into cards (description, schedule, copy-paste slash command,
|
||||
and a ``hermes://`` "Send to App" deep-link).
|
||||
|
||||
Output: ``website/static/api/automation-blueprints-index.json`` (served at
|
||||
``/docs/api/automation-blueprints-index.json``). Run automatically by
|
||||
``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Repo root = two levels up from website/scripts/.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
OUTPUT = REPO_ROOT / "website" / "static" / "api" / "automation-blueprints-index.json"
|
||||
|
||||
|
||||
def build_index() -> list:
|
||||
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
|
||||
|
||||
return [blueprint_catalog_entry(r) for r in CATALOG]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
index = build_index()
|
||||
except Exception as e: # pragma: no cover - import/build failure
|
||||
# Match extract-skills.py's resilience: write an empty array so the
|
||||
# docs build never hard-fails on a generator hiccup.
|
||||
sys.stderr.write(f"extract-automation-blueprints: {e}; writing empty index\n")
|
||||
index = []
|
||||
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(OUTPUT, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"))
|
||||
sys.stderr.write(f"extract-automation-blueprints: wrote {len(index)} blueprints -> {OUTPUT}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user