chore: merge upstream main into session activity branch

Resolve changelog and session cache conflicts while preserving last_message_at behavior.
This commit is contained in:
steven-ji 2026-08-27 09:34:34 +08:00
commit aca4f69c05
136 changed files with 9685 additions and 953 deletions

44
.github/CODEOWNERS vendored
View File

@ -8,6 +8,48 @@
#
# The workflow gates only understand individual @usernames (no @org/team
# entries).
#
# Order matters: GitHub applies the LAST matching pattern, so narrower rules
# go further down. Paths not listed here have no automatic reviewer.
# Telemetry, tracing, metrics.
/src/telemetry/ @akattelu @Rajat-Ahuja1997
# Data model, connections, configuration, LLM transport.
/src/db.py @akattelu @eisene
/src/models.py @akattelu @eisene
/src/config.py @akattelu @eisene
/src/cache/ @akattelu @eisene
/src/crud/ @akattelu @eisene
/migrations/ @akattelu @eisene
/src/llm/ @akattelu @eisene
# Client-facing surfaces and API shape.
/sdks/ @ajspig @akattelu
/mcp/ @ajspig @akattelu
/honcho-cli/ @ajspig @akattelu
/src/routers/ @ajspig @akattelu
/src/schemas/ @ajspig @akattelu
# The reasoning agents, their prompts, and shared agent tooling.
/src/deriver/ @eisene @akattelu
/src/dreamer/ @eisene @akattelu
/src/dialectic/ @eisene @akattelu
/src/utils/ @eisene @akattelu
# Deployment, and swappable storage and inference backends.
# /src/llm/backends/ must stay below /src/llm/ above — last match wins.
/docker/ @eisene @Rajat-Ahuja1997
/Dockerfile @eisene @Rajat-Ahuja1997
/docker-compose.yml.example @eisene @Rajat-Ahuja1997
/src/vector_store/ @eisene @Rajat-Ahuja1997
/src/llm/backends/ @eisene @Rajat-Ahuja1997
# Documentation and contributor-facing policy.
/docs/ @ajspig @akattelu
/README.md @ajspig @akattelu
/CONTRIBUTING.md @akattelu @ajspig
/SECURITY.md @Rajat-Ahuja1997 @ajspig
# Reviewers auto-requested on changes under .github/ (workflows, this file,
# templates).
@ -16,4 +58,4 @@
# CI-trigger allowlist only: this path matches no real file, so these people
# are never auto-requested for review, but the workflow gates still pick
# them up.
/ci-trigger-allowlist @3un01a @adavyas @ajspig @courtlandleer @erosika @lowyelling @matthewlanders @vintrocode
/ci-trigger-allowlist @ajspig @courtlandleer @erosika @lowyelling @vintrocode

View File

@ -1,76 +0,0 @@
---
name: "🐞 Bug Report"
about: "Report an issue to help the project improve."
title: "[Bug] "
labels: "bug"
assignees: ""
---
# **🐞 Bug Report**
## **Describe the bug**
<!-- A clear and concise description of what the bug is. -->
*
---
### **Is this a regression?**
<!-- Did this behaviour used to work in the previous version? -->
<!-- Yes, the last version in which this bug was not present was: ... -->
---
### **To Reproduce**
<!-- Steps to reproduce the error:
(e.g.:)
1. Use x argument / navigate to
2. Fill this information
3. Go to...
4. See error -->
<!-- Write the steps here (add or remove as many steps as needed)-->
1.
2.
3.
4.
---
### **Expected behaviour**
<!-- A clear and concise description of what you expected to happen. -->
*
---
### **Media prove**
<!-- If applicable, add screenshots or videos to help explain your problem. -->
---
### **Your environment**
<!-- use all the applicable bulleted list elements for this specific issue,
and remove all the bulleted list elements that are not relevant for this issue. -->
* OS: <!--[e.g. Ubuntu 5.4.0-26-generic x86_64 / Windows 1904 ...]-->
* Browser name and version:
* Honcho Server Version: <!-- e.g. v0.0.8 -->
* Honcho Client Version: <!-- e.g. Python v0.0.8 -->
---
### **Additional context**
<!-- Add any other context or additional information about the problem here.-->
*
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
To expedite issue processing, please search open and closed issues before submitting a new one.
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->

72
.github/ISSUE_TEMPLATE/1-bug-report.yml vendored Normal file
View File

@ -0,0 +1,72 @@
name: Bug report
description: Something is broken or incorrect in Honcho (API, deriver, SDK, managed offering, etc.).
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug. Please search [existing issues](https://github.com/plastic-labs/honcho/issues) first.
**Security vulnerability?** Do not use this form — report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md).
**Memory / recall quality** (wrong or noisy conclusions, weak dialectic answers) with no crash? Prefer the **Memory / recall quality** template.
- type: dropdown
id: deploy_mode
attributes:
label: Deploy mode
description: Where are you running Honcho?
options:
- Managed (api.honcho.dev / app.honcho.dev)
- Self-hosted
- Unsure
validations:
required: true
- type: input
id: version
attributes:
label: Honcho version
description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting.
placeholder: e.g. server v2.4.1, honcho-ai 2.1.0
validations:
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: Clear and concise description of what is wrong.
placeholder: When I…, Honcho…
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Minimal steps or a short script/API sequence. Redact secrets, JWTs, and production user content.
placeholder: |
1. Create a session with …
2. POST /v3/... with body …
3. Observe …
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and evidence
description: Relevant API or deriver logs or stack traces. Redact secrets and user content.
render: shell
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Config knobs, deployment notes, screenshots, related issues/PRs.
validations:
required: false

View File

@ -1,38 +0,0 @@
---
name: "💉 Failing Test"
about: "Report failing tests or CI jobs."
title: "[Test] "
labels: "Type: Test"
assignees: ""
---
# **💉 Failing Test**
## **Which jobs/test(s) are failing**
<!-- The CI jobs or tests that are failing -->
*
---
## **Reason for failure/description**
<!-- Try to describe why the test is failing or what we are missing to make it pass. -->
---
### **Media prove**
<!-- If applicable, add screenshots or videos to help explain your problem. -->
---
### **Additional context**
<!-- Add any other context or additional information about the problem here. -->
*
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
To expedite issue processing, please search open and closed issues before submitting a new one.
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->

View File

@ -0,0 +1,87 @@
name: Memory / recall quality
description: Conclusions, representations, or dialectic answers are wrong, noisy, missing, or low-quality — not a hard crash.
title: "[Quality] "
labels: ["quality"]
body:
- type: markdown
attributes:
value: |
Use this when Honcho runs without erroring, but **memory formation or recall quality** is off (bad conclusions, missed facts, weak chat answers, polluted representations, etc.).
For crashes, 5xxs, auth failures, or incorrect API mechanics, use the **Bug report** template instead.
**Do not paste production user content, full peer representations, or secrets.** Redact or invent a minimal synthetic example.
- type: dropdown
id: deploy_mode
attributes:
label: Deploy mode
options:
- Managed (api.honcho.dev / app.honcho.dev)
- Self-hosted
- Unsure
validations:
required: true
- type: input
id: version
attributes:
label: Honcho version
description: Server image tag or release, and SDK version if you use one. Write "managed" if you are not self-hosting.
placeholder: e.g. server v2.4.1, honcho-ai 2.1.0
validations:
required: true
- type: textarea
id: description
attributes:
label: What is wrong with the quality?
description: Describe the failure mode (noise, omission, contradiction, staleness, over/under-generalization, etc.).
placeholder: After ingesting messages about X, Honcho concludes Y / chat answers Z…
validations:
required: true
- type: textarea
id: repro
attributes:
label: Minimal scenario
description: >
Smallest synthetic message sequence or setup that triggers the issue.
Prefer invented names/facts over real user data. Include observer/observed
peer setup if relevant (self vs cross-peer).
placeholder: |
1. Peers: alice (user), bot (agent); session S
2. Messages ingested:
3. Query / conclusion listing shows:
validations:
required: true
- type: textarea
id: config
attributes:
label: Relevant config
description: >
Custom instructions, provider/model, deriver/dream settings, or workspace/peer
config that affects reasoning. Redact secrets.
placeholder: |
Provider/model:
Custom instructions: (summary or redacted)
Other:
validations:
required: false
- type: textarea
id: evidence
attributes:
label: Evidence
description: Redacted conclusion text, chat excerpts, or counts that show the failure. No production PII.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Frequency, scale (message/conclusion counts), related issues, workarounds.
validations:
required: false

View File

@ -0,0 +1,58 @@
name: Feature request
description: Propose a new capability or an improvement to an existing one.
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Tell us what problem you are trying to solve. Concrete use cases beat abstract wishlists.
Questions about how to use Honcho belong on [Discord](https://discord.gg/honcho), not here.
- type: dropdown
id: request_type
attributes:
label: Request type
options:
- New capability
- Improve an existing capability
- API / SDK surface
- Managed offering
- Docs / DX
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem
description: What is hard or impossible today? Who hits this?
placeholder: I'm always frustrated when… / My integration needs…
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What you would like Honcho to support. Sketches and API shapes welcome.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Workarounds, other APIs, or designs you already tried or ruled out.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Links, prior art, screenshots, related issues/PRs.
validations:
required: false

View File

@ -1,42 +0,0 @@
---
name: "🚀🆕 Feature Request"
about: "Suggest an idea or possible new feature for this project."
title: ""
labels: 'feature'
assignees: ''
---
# **🚀 Feature Request**
## **Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
*
---
## **Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
*
---
## **Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
*
---
### **Additional context**
<!-- Add any other context or additional information about the problem here.-->
*
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
To expedite issue processing, please search open and closed issues before submitting a new one.
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->

View File

@ -0,0 +1,70 @@
name: Integration request
description: Add Honcho to an app store, agent framework, plugin marketplace, or other third-party surface — or improve an existing integration.
title: "[Integration] "
labels: ["integration"]
body:
- type: markdown
attributes:
value: |
Use this when you want Honcho available in (or better supported by) an external product surface — app stores, agent frameworks, plugin marketplaces, IDE extensions, MCP clients, etc.
For core API/SDK product features that are not about a third-party surface, use the **Feature request** template instead.
- type: dropdown
id: request_kind
attributes:
label: What kind of request is this?
options:
- New integration / listing (Honcho is not there yet)
- Improve an existing integration
- Official plugin / extension
- Marketplace or app-store listing
- Docs / guide for integrating with a specific tool
- Other
validations:
required: true
- type: input
id: target
attributes:
label: Target product or platform
description: Name of the app, framework, marketplace, or tool.
placeholder: e.g. Claude Code, Cursor, CrewAI, OpenClaw, VS Code Marketplace…
validations:
required: true
- type: input
id: target_url
attributes:
label: Link (if any)
description: Docs, marketplace page, repo, or product URL.
placeholder: https://…
validations:
required: false
- type: textarea
id: why
attributes:
label: Why does this matter?
description: Who would use it, and what does the integration unlock?
validations:
required: true
- type: textarea
id: shape
attributes:
label: What should the integration look like?
description: >
e.g. one-click install, MCP server listing, native memory backend,
SDK recipe, plugin with slash commands, env-var setup, etc.
Link to prior art or a sketch if you have one.
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: Related issues, community demand, constraints, offers to help build it.
validations:
required: false

View File

@ -8,6 +8,10 @@ assignees: ""
---
# **📚 Documentation Issue Report**
**Security vulnerability?** Do not use this form — report privately via [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md).
GitHub issues are public. Redact secrets, JWTs, and production user content.
## **Describe the bug**
<!-- A clear and concise description of what the bug is. -->
@ -33,8 +37,8 @@ assignees: ""
---
### **Media prove**
<!-- If applicable, add screenshots or videos to help explain your problem. -->
### **Screenshots and videos**
<!-- If applicable, add screenshots or videos to help explain your problem. Redact secrets and production content. -->
---
@ -46,7 +50,7 @@ assignees: ""
---
### **Additional context**
<!-- Add any other context or additional information about the problem here.-->
<!-- Add any other context about the problem. Redact secrets and production user content. -->
*

View File

@ -1,42 +0,0 @@
---
name: "🚀➕ Enhancement Request"
about: "Suggest an enhancement for this project. Improve an existing feature"
title: ""
labels: "Type: Enhancement"
assignees: ""
---
# **🚀 Enhancement Request**
## **Is your enhancement request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
*
---
## **Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
*
---
## **Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
*
---
### **Additional context**
<!-- Add any other context or additional information about the problem here.-->
*
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
To expedite issue processing, please search open and closed issues before submitting a new one.
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->

View File

@ -1,93 +0,0 @@
---
name: "⚠️ Security Report"
about: "Report an issue to help the project improve."
title: ""
labels: "security"
assignees: ""
---
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
READ CAREFULLY IF YOUR ISSUE REPORT CONTAINS SENSIBLE OR PRIVATE DATA:
(data that might be leaked or subtracted from our servers due to this
security issue).
If this security report (or the guide on how to "identify the security bug") includes
certain personal information or involves personal identifiable data, or you believe
that the data that you might leak by exposing the way on how to attack the project
could be considered as a data leak or could violate the privacy of any kind of
data or sensible data, please do not post it here and directly email the developer:
(hello@plasticlabs.ai). You should post the issue with the least amount of
sensible or private data as possible to help us manage the security issue, and
with the extra data sent from your email to the developer (if any), we will deeply
analyze and try to fix it as fast as possible.
If you are in doubt about the data that you might post here (screenshots or media
also, count as data), please directly email us.
The data that must NOT be posted here:
* Legal and/or full names
* Names or usernames combined with other identifiers like phone numbers or email addresses
* Health or financial information (including insurance information, social security numbers, etc.)
* Information about political or religious affiliations
* Information about race, ethnicity, sexual orientation, gender, or other identifying information that could be used for discriminatory purposes
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->
# **⚠️ Security Report**
## **Describe the security issue**
<!-- A clear and concise description of what the bug is. -->
*
---
### **To Reproduce**
<!-- Steps to reproduce the error:
(e.g.:)
1. Use x argument / navigate to
2. Fill this information
3. Go to...
4. See error -->
<!-- Write the steps here (add or remove as many steps as needed)-->
1.
2.
3.
4.
---
### **Expected behaviour**
<!-- A clear and concise description of what you expected to happen. -->
*
---
### **Media prove**
<!-- If applicable, add screenshots or videos to help explain your problem. -->
---
### **Your environment**
<!-- use all the applicable bulleted list elements for this specific issue,
and remove all the bulleted list elements that are not relevant for this issue. -->
* OS: <!--[e.g. Ubuntu 5.4.0-26-generic x86_64 / Windows 1904 ...]-->
* Browser name and version:
* Honcho Server Version: <!--[e.g. v0.0.1]-->
* Honcho Client Version: <!--[e.g. Python v0.0.1]-->
---
### **Additional context**
<!-- Add any other context or additional information about the problem here.-->
*

View File

@ -1,25 +0,0 @@
---
name: "❓ Question or Support Request"
about: "Questions and requests for support."
title: ""
labels: "question"
assignees: ""
---
# **❓ Question or Support Request**
## **Describe your question or ask for support.**
<!-- A clear and concise description of what your doubt is. -->
*
<!--📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛
Before posting any questions or asking for support, first read the project's README.md file and
(if there is any) the WIKI pages or any other additional documentation that might be listed
in the project's README.md file.
To expedite issue processing, please search open and closed issues before submitting a new one.
📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛📛-->

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Report a security vulnerability
url: https://github.com/plastic-labs/honcho/security/advisories/new
about: Private vulnerability reporting only — do not file public security issues.
- name: Question or support
url: https://discord.gg/honcho
about: Ask the community and maintainers on Discord.
- name: Documentation
url: https://honcho.dev/docs
about: Guides, API reference, and self-hosting docs.

286
.github/scripts/issue-gate.js vendored Normal file
View File

@ -0,0 +1,286 @@
'use strict';
/**
* Issue gate shared logic for `.github/workflows/issue-gate.yml` (immediate
* feedback on pull request events) and `.github/workflows/pr-sweeper.yml`
* (deferred re-check, close, and stale-draft cleanup).
*
* Both workflows `require` this file through actions/github-script, so it must
* stay dependency-free: neither job runs an install step.
*
* See CONTRIBUTING.md for the policy this enforces.
*/
const REQUIRED_LABEL = 'maintainer-approved';
const GATE_LABEL = 'needs-approved-issue';
const EXEMPT_LABEL = 'gate-exempt';
const MARKER = '<!-- issue-gate -->';
const DISCORD = 'http://discord.gg/honcho';
// Hours a labelled pull request has before the sweeper closes it. Measured from
// the notice comment, so the clock starts when the author was actually told —
// not when the pull request was opened.
const GRACE_HOURS = 72;
// Days without activity before a draft from outside the org is closed.
const DRAFT_STALE_DAYS = 30;
const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name === name);
const isBot = (account) => Boolean(account) && account.type === 'Bot';
/**
* Why this pull request is exempt from the gate, or null if it is not.
*
* Single source of truth: every caller that acts on a pull request runs this.
*/
const exemptReason = async ({ github, owner, repo, pr }) => {
if (isBot(pr.user)) return 'author is a bot';
if (hasLabel(pr, EXEMPT_LABEL)) return `carries the ${EXEMPT_LABEL} label`;
const username = pr.user && pr.user.login;
if (!username) return null;
const permission = await repoPermission({ github, owner, repo, username });
if (WRITE_PERMISSIONS.includes(permission)) {
return `author has ${permission} permission`;
}
return null;
};
// Repo roles that skip the gate. `read` / `triage` do not.
const WRITE_PERMISSIONS = ['admin', 'maintain', 'write'];
/** Highest repo permission for `username`, or null if they are not a collaborator. */
async function repoPermission({ github, owner, repo, username }) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username,
});
return data.permission;
} catch (err) {
if (err && err.status === 404) return null;
throw err;
}
}
const CLOSING_ISSUES = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 20) {
nodes {
number
state
labels(first: 50) { nodes { name } }
}
}
}
}
}
`;
/**
* Decide whether a pull request clears the gate.
*
* Reads GitHub's own resolved issue links rather than parsing the body, so both
* `Fixes #123` and the sidebar "Development" link count. A bare `#123` mention
* deliberately does not that is a reference, not a claim to close.
*
* @returns {Promise<{passed: boolean, skipped?: string, issue?: number, reason?: string}>}
*/
async function checkGate({ github, owner, repo, pr }) {
if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' };
if (pr.draft) return { passed: true, skipped: 'pull request is a draft' };
const exempt = await exemptReason({ github, owner, repo, pr });
if (exempt) return { passed: true, skipped: exempt };
const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number });
const issues = data.repository.pullRequest.closingIssuesReferences.nodes;
if (issues.length === 0) {
return { passed: false, reason: 'This pull request is not linked to an issue.' };
}
const approved = issues.find(
(i) => i.state === 'OPEN' && i.labels.nodes.some((l) => l.name === REQUIRED_LABEL),
);
if (approved) return { passed: true, issue: approved.number };
const detail = issues
.map((i) => `#${i.number} (${i.state === 'CLOSED' ? 'closed' : 'not approved'})`)
.join(', ');
return {
passed: false,
reason:
`The linked ${issues.length === 1 ? 'issue is' : 'issues are'} not open with the ` +
`\`${REQUIRED_LABEL}\` label: ${detail}.`,
};
}
function noticeBody({ owner, repo, reason }) {
return [
MARKER,
'Thanks for the contribution. This pull request does not clear our issue gate yet.',
'',
`**${reason}**`,
'',
`Every pull request to Honcho needs to be linked to an open issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built — it means nobody spends time on a change we cannot merge.`,
'',
'To get this moving:',
'',
`1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`,
`2. Make the case for it in [Discord](${DISCORD}) — maintainers are most active there, and it is by far the fastest route to a decision.`,
`3. Once the issue has the label, link it: put \`Fixes #<number>\` in this pull request's description, or use **Development** in the sidebar.`,
'',
`**This will close automatically in ${GRACE_HOURS} hours if it is still unlinked.** Nothing is lost if that happens — link the issue, reopen, and it goes into the review queue.`,
'',
`See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this is wrong, say so here and a maintainer will take a look.`,
].join('\n');
}
/**
* Every gate notice this bot posted on a pull request, oldest first.
*
* Authorship is part of the test, not decoration. MARKER is an invisible HTML
* comment, so anyone who can comment on a public repository can paste it. If
* user comments counted, a third party could post one on someone else's pull
* request: `runGate` posts a notice only when none exists, so the author would
* never be told, and `runSweep` would then measure the grace window from the
* stranger's timestamp and close them unwarned.
*/
async function findNotices({ github, owner, repo, number }) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
});
return comments.filter((c) => isBot(c.user) && (c.body || '').includes(MARKER));
}
/**
* Drop the gate label and delete the notice.
*
* Deleting matters: `runGate` posts a notice only when none exists, and the
* sweeper measures grace from the notice timestamp. A notice left behind after
* the gate clears would make a later re-block look weeks old and be closed with
* no warning.
*/
async function clearGate({ github, owner, repo, pr }) {
if (hasLabel(pr, GATE_LABEL)) {
await github.rest.issues
.removeLabel({ owner, repo, issue_number: pr.number, name: GATE_LABEL })
.catch(() => {});
}
for (const notice of await findNotices({ github, owner, repo, number: pr.number })) {
await github.rest.issues
.deleteComment({ owner, repo, comment_id: notice.id })
.catch(() => {});
}
}
/**
* Entry point for `.github/workflows/issue-gate.yml`.
* Labels and explains. Never closes that is the sweeper's job.
*/
async function runGate({ github, core, context }) {
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const result = await checkGate({ github, owner, repo, pr });
if (result.passed) {
core.info(
result.skipped ? `Skipping gate: ${result.skipped}` : `Gate passed via #${result.issue}`,
);
await clearGate({ github, owner, repo, pr });
return;
}
core.warning(`Gate failed: ${result.reason}`);
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.number, labels: [GATE_LABEL],
});
const notices = await findNotices({ github, owner, repo, number: pr.number });
if (notices.length > 0) return;
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: noticeBody({ owner, repo, reason: result.reason }),
});
}
/** Entry point for `.github/workflows/pr-sweeper.yml`. */
async function runSweep({ github, core, context, dryRun }) {
const { owner, repo } = context.repo;
const act = async (what, fn) => {
core.info(dryRun ? `[dry run] ${what}` : what);
if (!dryRun) await fn();
};
const close = (pr, body) => async () => {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' });
};
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
core.info(`${prs.length} open pull requests${dryRun ? ' (dry run)' : ''}`);
// Re-check everything wearing the gate label. Never close blind: a pull request
// linked through the sidebar fires no webhook, so the gate workflow cannot have
// noticed it — this pass is the only thing that will.
for (const pr of prs.filter((p) => hasLabel(p, GATE_LABEL))) {
const result = await checkGate({ github, owner, repo, pr });
if (result.passed) {
const why = result.skipped || `via #${result.issue}`;
await act(`#${pr.number}: gate now clear (${why})`, async () => {
await clearGate({ github, owner, repo, pr });
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number,
body: 'The issue link is in place — this pull request has cleared the gate and is waiting on review.',
});
});
continue;
}
const [notice] = await findNotices({ github, owner, repo, number: pr.number });
if (!notice) {
core.info(`#${pr.number}: labelled but never notified — leaving it for the gate workflow`);
continue;
}
const hours = (Date.now() - Date.parse(notice.created_at)) / 3_600_000;
if (hours < GRACE_HOURS) {
core.info(`#${pr.number}: ${Math.round(GRACE_HOURS - hours)}h of grace left`);
continue;
}
await act(`#${pr.number}: closing — notified ${Math.round(hours)}h ago, still failing`, close(pr,
`Closing this: ${GRACE_HOURS} hours have passed and the gate is still not clear. This is not a judgement on the code. Link an approved issue and reopen — it goes straight into the review queue.`,
));
}
// Stale drafts. The gate skips drafts entirely, so they never carry the label;
// this pass keys off inactivity and applies the shared exemptions itself.
for (const pr of prs.filter((p) => p.draft)) {
const exempt = await exemptReason({ github, owner, repo, pr });
if (exempt) {
core.info(`#${pr.number}: leaving stale draft alone — ${exempt}`);
continue;
}
const days = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000;
if (days < DRAFT_STALE_DAYS) continue;
await act(`#${pr.number}: closing stale draft — ${Math.round(days)}d without activity`, close(pr,
`Closing this draft after ${DRAFT_STALE_DAYS} days without activity, to keep the pull request list readable. Reopen whenever you pick it back up — nothing here is lost.`,
));
}
}
module.exports = {
checkGate, runGate, runSweep, noticeBody, findNotices, exemptReason,
REQUIRED_LABEL, GATE_LABEL, EXEMPT_LABEL, MARKER, GRACE_HOURS, DRAFT_STALE_DAYS,
};

161
.github/scripts/issue-gate.test.js vendored Normal file
View File

@ -0,0 +1,161 @@
'use strict';
// Self-check for the gate decision logic. No framework, no install:
// node .github/scripts/issue-gate.test.js
// Covers checkGate() only — the side-effecting halves (runGate/runSweep) are
// exercised against the real API via `pr-sweeper.yml`'s dry_run dispatch.
const assert = require('node:assert');
const {
checkGate, findNotices, runSweep, REQUIRED_LABEL, EXEMPT_LABEL, MARKER,
} = require('./issue-gate.js');
const pull = (over = {}) => ({
number: 1, state: 'open', draft: false,
user: { type: 'User', login: 'alice' }, labels: [],
...over,
});
const notCollaborator = () => {
const err = new Error('Not Found');
err.status = 404;
throw err;
};
// `linked` is the list of issues GitHub resolves as closing references.
const stub = (linked, permission) => ({
graphql: async () => ({
repository: { pullRequest: { closingIssuesReferences: {
nodes: linked.map((i) => ({
number: i.number, state: i.state || 'OPEN',
labels: { nodes: (i.labels || []).map((name) => ({ name })) },
})),
} } },
}),
rest: {
repos: {
getCollaboratorPermissionLevel: async () => {
if (!permission) return notCollaborator();
return { data: { permission } };
},
},
},
});
const run = (linked, over, permission) =>
checkGate({ github: stub(linked, permission), owner: 'o', repo: 'r', pr: pull(over) });
const cases = [
['no linked issue fails', () => run([]), (r) => r.passed === false],
['linked but unapproved fails', () => run([{ number: 7 }]), (r) => r.passed === false],
['linked and approved passes',
() => run([{ number: 7, labels: [REQUIRED_LABEL] }]),
(r) => r.passed === true && r.issue === 7],
['approved but closed fails',
() => run([{ number: 7, state: 'CLOSED', labels: [REQUIRED_LABEL] }]),
(r) => r.passed === false],
['picks the approved one out of several',
() => run([{ number: 7 }, { number: 8, labels: [REQUIRED_LABEL] }]),
(r) => r.passed === true && r.issue === 8],
// Exemptions.
['write permission skips', () => run([], {}, 'write'), (r) => r.passed === true],
['maintain permission skips', () => run([], {}, 'maintain'), (r) => r.passed === true],
['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true],
['draft skips', () => run([], { draft: true }), (r) => r.passed === true],
[`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true],
['triage permission is still gated', () => run([], {}, 'triage'), (r) => r.passed === false],
['MEMBER association without write is still gated',
() => run([], { author_association: 'MEMBER' }),
(r) => r.passed === false],
['CONTRIBUTOR with write skips',
() => run([], { author_association: 'CONTRIBUTOR' }, 'write'),
(r) => r.passed === true],
];
// --- findNotices: only the bot's own notices count -------------------------
// A stranger pasting the invisible MARKER into a comment must not suppress the
// notice or become the grace-window clock.
const commentsStub = (comments) => ({
paginate: async () => comments,
rest: { issues: { listComments: null } },
});
const noticeCases = [
['a user comment carrying MARKER is not a notice',
[{ id: 1, user: { type: 'User' }, body: `sneaky ${MARKER}`, created_at: 'x' }], 0],
['a bot comment carrying MARKER is a notice',
[{ id: 2, user: { type: 'Bot' }, body: `${MARKER}\nnotice`, created_at: 'x' }], 1],
['a bot comment without MARKER is not a notice',
[{ id: 3, user: { type: 'Bot' }, body: 'unrelated', created_at: 'x' }], 0],
['a user MARKER does not mask the real bot notice',
[{ id: 4, user: { type: 'User' }, body: MARKER, created_at: 'x' },
{ id: 5, user: { type: 'Bot' }, body: MARKER, created_at: 'y' }], 1],
];
// --- runSweep: the stale-draft pass must honour every exemption ------------
const draft = (over) => ({
number: 9, draft: true, state: 'open', labels: [],
user: { type: 'User', login: 'alice' },
updated_at: new Date(Date.now() - 400 * 86400_000).toISOString(),
...over,
});
async function sweepClosed(pr, permission) {
const closed = [];
const github = {
paginate: async (route) => (route === 'pulls' ? [pr] : []),
rest: {
pulls: {
list: 'pulls',
update: async ({ pull_number }) => closed.push(pull_number),
},
issues: { listComments: 'comments', createComment: async () => {} },
repos: {
getCollaboratorPermissionLevel: async () => {
if (!permission) return notCollaborator();
return { data: { permission } };
},
},
},
};
await runSweep({
github, core: { info() {}, warning() {} },
context: { repo: { owner: 'o', repo: 'r' } }, dryRun: false,
});
return closed;
}
const sweepCases = [
['stale draft from an outside author closes', draft({}), 1],
['stale draft from a bot is left alone', draft({ user: { type: 'Bot' } }), 0],
['stale draft from a writer is left alone', draft({}), 0, 'write'],
[`stale draft with ${EXEMPT_LABEL} is left alone`, draft({ labels: [{ name: EXEMPT_LABEL }] }), 0],
['recent draft is left alone', draft({ updated_at: new Date().toISOString() }), 0],
];
(async () => {
let failed = 0;
for (const [name, comments, want] of noticeCases) {
const got = (await findNotices({ github: commentsStub(comments), owner: 'o', repo: 'r', number: 1 })).length;
if (got === want) console.log(` ok ${name}`);
else { failed++; console.log(` FAIL ${name} -> ${got} notices, wanted ${want}`); }
}
for (const [name, pr, want, permission] of sweepCases) {
const got = (await sweepClosed(pr, permission)).length;
if (got === want) console.log(` ok ${name}`);
else { failed++; console.log(` FAIL ${name} -> closed ${got}, wanted ${want}`); }
}
for (const [name, thunk, ok] of cases) {
const result = await thunk();
if (ok(result)) {
console.log(` ok ${name}`);
} else {
failed++;
console.log(` FAIL ${name} -> ${JSON.stringify(result)}`);
}
}
assert.strictEqual(failed, 0, `${failed} case(s) failed`);
console.log(`\n${cases.length + noticeCases.length + sweepCases.length} passed`);
})();

37
.github/workflows/issue-gate.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Issue Gate
# Labels pull requests that are not linked to an issue carrying the
# `maintainer-approved` label, and comments explaining how to fix it.
#
# This workflow never closes anything. `pr-sweeper.yml` re-checks later and closes
# only after the grace period — that gives contributors time to link an issue, and
# gives maintainers time to wave through a one-line fix. It is also the only thing
# that can notice a sidebar issue link, which fires no webhook of its own.
#
# `pull_request_target` is required so the job has write access on pull requests
# from forks. It must therefore NEVER run code from the pull request. The checkout
# below is safe because on `pull_request_target` actions/checkout defaults to the
# BASE ref, which is repo-trusted code. Never point it at `pr.head.sha`.
#
# Not triggered on `synchronize`: re-running on every push would be noise.
# Drafts are ignored until marked ready.
on:
pull_request_target:
types: [opened, edited, reopened, ready_for_review]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v7
with:
script: |
const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`);
await gate.runGate({ github, core, context });

41
.github/workflows/pr-sweeper.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: PR Sweeper
# Deferred half of the issue gate. Every six hours:
#
# 1. Re-check every pull request carrying `needs-approved-issue`. Clear the ones
# that now link an approved issue; close the ones still failing 72h after they
# were told. The re-check is the point — linking an issue through the sidebar
# fires no webhook, so `issue-gate.yml` never sees it.
# 2. Close drafts from outside the org after 30 days without activity.
#
# Runs on `schedule`, so it never touches pull request code and needs none of the
# `pull_request_target` precautions. Dispatch manually with dry_run to see what it
# would do before it does it.
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Log intended actions without closing anything'
type: boolean
default: true
permissions:
contents: read
issues: write
pull-requests: write
jobs:
sweep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/github-script@v7
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
with:
script: |
const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`);
await gate.runSweep({ github, core, context, dryRun: process.env.DRY_RUN === 'true' });

View File

@ -26,3 +26,11 @@ jobs:
run: uv sync --all-extras --dev
- name: run basedpyright
run: uv run basedpyright
issue-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The gate runs from `pull_request_target`, where a crash is invisible until
# a contributor's PR is silently ungated. Check it here instead.
- run: node .github/scripts/issue-gate.test.js

View File

@ -57,7 +57,10 @@ jobs:
name: Run Unified Tests
runs-on: ${{ fromJSON(format('[{0}]', needs.start-runner.outputs.runner-labels)) }}
needs: start-runner
if: needs.start-runner.outputs.runner-ready == 'true'
# !cancelled() so this doesn't inherit gate's skip on push events.
if: >-
!cancelled() &&
needs.start-runner.outputs.runner-ready == 'true'
timeout-minutes: 90
environment: unified-tests
permissions:

4
.gitignore vendored
View File

@ -6,8 +6,8 @@ api/docker-compose.yml
*.db
data
redis-data
docker-compose.yml
compose.yml
/docker-compose.yml
/compose.yml

View File

@ -11,6 +11,44 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Session responses now expose nullable `last_message_at`, backfilled and maintained from the newest message timestamp. `POST /v3/workspaces/{workspace_id}/sessions/list` accepts `sort_by=created_at|last_message_at` alongside the existing `reverse` parameter, with stable ID tie-breaking and sessions without messages placed last in either direction (#965).
## [3.1.0] - 2026-08-25
### Added
- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884)
- `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897)
- Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904)
- Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931)
- MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020)
- MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974)
- Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055)
- Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927)
### Changed
- Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066)
- Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015)
- LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011)
- Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014)
- Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058)
- Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028)
### Fixed
- Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065)
- Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059)
- Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791)
- The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947)
- `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036)
- Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019)
- Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034)
- `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018)
- `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024)
- Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960)
- OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938)
- Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745)
- MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004)
## [3.0.12] - 2026-08-10
### Added
@ -182,7 +220,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Vector store queries no longer fetch embedding vectors — only document metadata is returned, reducing payload size and DB load (pgvector, lancedb, turbopuffer) (#682)
- Langfuse trace metadata now includes `namespace`, `model`, and `provider` so traces can be filtered by deployment slice (#565)
- Deriver: model-aware tokenizer (replaces the previously hardcoded encoding) and explicit guard on empty message content (#647)
- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (DEV-1733) (#656)
- Dialectic level defaults now merge correctly with per-level overrides in `src/config` (#656)
- Default dialectic tool choice switched from forced/required to `auto` (#630)
- Vector sync given a substantial retry budget to tolerate transient embedding provider outages (#604)
- `AgentToolConclusionsDeletedEvent` payload now carries `levels` for parity with the rest of the conclusion event surface (#612)
@ -200,7 +238,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Removed strict parameter validation for thinking params on Anthropic and OpenAI transports — was rejecting valid per-transport configs (#686)
- `reverse` query parameter is now honored on the v3 workspace list (`POST /v3/workspaces/list`), peer list (`POST /v3/workspaces/{workspace_id}/peers/list`), workspace-scoped session list (`POST /v3/workspaces/{workspace_id}/sessions/list`), and peer-scoped session list (`POST /v3/workspaces/{workspace_id}/peers/{peer_id}/sessions`). Honcho SDKs at 2.1.0+ were already sending `reverse=true` for these routes but the server silently ignored it. Ties on `created_at` now fall back to the internal nanoid `id` so ordering remains stable across pages (#685)
- LLM client factories now receive `base_url` from `LLMSettings` for default providers — previously the override path honored `base_url` but the default path didn't, so operators pointing at OpenAI-compatible proxies via `LLM__OPENAI_BASE_URL` were ignored (#643, fixes #641)
- Internal N+1 query in dialectic agent tool execution (DEV-1721) — collapsed per-iteration DB lookups into a single fetch (#652)
- Internal N+1 query in dialectic agent tool execution — collapsed per-iteration DB lookups into a single fetch (#652)
- Dreamer threshold and time-guard semantics: `check_and_schedule_dream` count filter now includes only `documents.level == 'explicit'` (dreamer-created levels are output, not input, and were inflating the threshold and creating a feedback loop); `last_dream_at` write relocated from `enqueue_dream` into `process_dream` so duplicate enqueues or failed runs no longer reset the 8-hour time guard (#573)
- Deriver: blank observations are filtered out before embedding (previously triggered noisy embedding calls and persisted empty rows); blank-observation filtering unified across tool paths (#615)
- Surprisal module: filter for level observations changed from `{"level": levels}` to `{"level": {"in": levels}}``apply_filter()` requires operator syntax, so the prior call silently returned 0 results and made the entire Surprisal phase of the Dream cycle a no-op (#581, fixes #559)

View File

@ -1,215 +1,372 @@
# Contributing to Honcho
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
<!-- This file is mirrored at docs/v3/contributing/guidelines.mdx. Update both. -->
## Getting Started
Thanks for your interest in contributing. This guide covers how work gets accepted, how
Honcho is put together, and what a mergeable pull request looks like.
Before you start contributing, please:
Honcho is a small team maintaining a project that gets more proposals than we can review.
The rules below exist so that the work you do has somewhere to land — not to keep you out.
1. **Set up your development environment** - Follow the [Local Development guide](./README.md#local-development) in the README to get Honcho running locally.
## Contents
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
- [Before you write code](#before-you-write-code)
- [What gets prioritized](#what-gets-prioritized)
- [If you're an agent](#if-youre-an-agent)
- [How Honcho works](#how-honcho-works)
- [Where to change what](#where-to-change-what)
- [Local setup](#local-setup)
- [Making the change](#making-the-change)
- [Opening the pull request](#opening-the-pull-request)
- [Reporting bugs and requesting features](#reporting-bugs-and-requesting-features)
- [Security](#security)
- [License](#license)
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
## Before you write code
## Contribution Workflow
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
### 1. Fork and Clone
A pull request that is not linked to an approved issue gets labelled
`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one
before it is closed automatically. Reopening costs nothing once the link is in place. This
is automated. We do this because an unreviewable backlog helps nobody: a PR against an
unapproved issue is work you did that we may not be able to merge, no matter how good it
is.
1. Fork the repository on GitHub
2. Clone your fork locally:
So, in order:
```bash
git clone https://github.com/YOUR_USERNAME/honcho.git
cd honcho
```
1. **Find approved work.** Browse
[issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved).
That label is the queue of things we have agreed should be built. Anything in it is fair
game — comment on the issue to claim it.
3. Add the upstream repository as a remote:
2. **Or open an issue and get it approved.** Use the
[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers
triage and apply the label.
```bash
git remote add upstream https://github.com/plastic-labs/honcho.git
```
3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).**
This is the fastest path by a wide margin. Maintainers are more active there than in the
issue tracker, and a five-minute conversation about what you want to build usually
resolves whether it fits before either side spends real time on it.
### 2. Create a Branch
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or
**Development → link an issue** in the sidebar. Both work.
Create a new branch for your feature or bug fix:
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an
obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the
issue linkage.
## What gets prioritized
Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells
you a lot about how likely it is to get approved.
| Axis | What it covers |
| --- | --- |
| **Observability** | Understanding how Honcho behaves in production — telemetry, tracing, CloudEvents, metrics. |
| **Memory quality** | Better conclusions from the same input — the deriver, dreamer, and dialectic; eval results. |
| **Developer experience** | Fitting cleanly into more application architectures — SDKs, scopes, composable peers, the CLI. |
| **Breadth of input** | Widening what Honcho can ingest and represent — multimodal and non-conversational data. |
| **Ubiquity** | Reachable wherever a developer already works — integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. |
| **Reliability and cost** | Trustworthy in production — connection and concurrency hardening, queue throughput, cost per token. |
In practice, **Ubiquity** and **Developer experience** are where outside contributions land
most easily. A new integration, a self-hosting rough edge, a vector-store or inference
backend, an SDK ergonomics fix — these are additive and rarely collide with work already in
flight.
Changes to the reasoning pipeline itself — deriver prompts, dialectic tool design, dreamer
strategy — are the hardest to accept from outside. Not because they are unwelcome, but
because they are measured against eval results we run internally, and they frequently
conflict with in-flight work. Talk to us in Discord first, always.
## If you're an agent
If you are a coding agent working on this repository, read this section before writing code.
The most common failure we see is a well-formed, well-tested pull request against an issue
that was never approved. That gets closed, and the work is wasted.
- **Check the gate first.** Before writing code:
```bash
gh issue view <N> --repo plastic-labs/honcho --json number,title,state,labels
```
Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is
not in the labels. Report that to the person you are working with instead of proceeding.
- **Do not open a PR in order to establish the issue link afterwards.** The issue comes
first.
- **Do not report checks you did not run.** If you did not execute the test command, say so.
A PR body claiming a green run that did not happen costs a maintainer more time than no
claim at all.
- **Use the checklist.** [`skills/pre-pr/SKILL.md`](./skills/pre-pr/SKILL.md) in this repo
encodes the gate, the test-layer matrix, and the PR body format. If your harness supports
skills, invoke it rather than reimplementing the checks.
## How Honcho works
Enough architecture to find your way around. For the user-facing model — what a Peer is, what
`get_context` returns — see [Core Concepts in the README](./README.md#core-concepts) and the
[documentation](https://honcho.dev/docs/).
### Two processes
Honcho runs as two cooperating processes over a shared Postgres database and Redis cache.
| | API server | Deriver worker |
| --- | --- | --- |
| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` |
| Entry | `src/main.py` | `src/deriver/__main__.py` |
| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler |
| Hosts | The Dialectic agent, inline on the request path | Everything else |
The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**,
with the single exception of the Dialectic chat endpoint, which is synchronous by nature.
If you are adding something slow, it belongs in the worker.
The deriver is a separate process. If messages go in and nothing ever comes out, the usual
cause is that nobody started it.
### The path of a message
Worth tracing once, because it crosses most of the codebase:
1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`.
2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item`
rows — one set of work per observing peer.
3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a
session are processed in order.
4. `process_item()` in `src/deriver/consumer.py` dispatches on task type — representation,
summary, deletion, reconciliation.
5. For a representation task, `process_representation_tasks_batch()` in
`src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and
writes the resulting conclusions into the collection keyed by the
`(observer, observed)` peer pair.
6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat
request.
Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with
`sync_state='pending'` and embedded asynchronously by the Reconciler
(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process.
### The four agents
They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM
client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in
`src/config.py`.
| Agent | Where | Shape |
| --- | --- | --- |
| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop — this is a deliberate cost and latency tradeoff. |
| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. |
| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. |
| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers — short and long summaries at different message counts. |
Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and
`src/dreamer/specialists.py`.
### A note on naming
What the public API and documentation call **conclusions** are called **observations**
throughout the code — `create_observations`, `get_observation_context`, and so on. Likewise
**collections** and **documents** are internal storage concepts that are not exposed
directly through the API. Do not rename across that boundary in a drive-by change; the
public and internal vocabularies are being reconciled deliberately.
## Where to change what
| I want to change... | Start here |
| --- | --- |
| An HTTP endpoint | `src/routers/` — one module per resource |
| A database query | `src/crud/` — mirrors the router layout |
| The database schema | `src/models.py`, plus a migration in `migrations/versions/` |
| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` |
| A tool an agent can call | `src/utils/agent_tools.py` — definitions plus the per-agent tool lists |
| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` |
| LLM provider behavior | `src/llm/backends/``anthropic.py`, `gemini.py`, `openai.py` |
| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` |
| Telemetry or metrics | `src/telemetry/` — see the notes in `CLAUDE.md` before adding an event type |
| Authentication and scoping | `src/security.py`, `src/dependencies.py` |
| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` |
| The CLI | `honcho-cli/` |
| The MCP server | `mcp/` |
| Public documentation | `docs/v3/` — Mintlify; nav lives in `docs/docs.json` |
Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house
conventions, and is worth skimming even if you are not using an agent.
## Local setup
To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key — not the Honcho API key from `honcho init`) — [CLI in the README](./README.md#cli).
To **develop this repo**, clone it and:
```bash
uv sync # create the venv and install dependencies
uv run alembic upgrade head # apply migrations
```
Run both processes, in separate terminals:
```bash
uv run fastapi dev src/main.py # API server, reloads on change
uv run python -m src.deriver # background worker
```
Everything Python goes through `uv run`. Redis is optional for local development; without it
caching is simply disabled.
## Making the change
### Branches and commits
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix-name
```
**Branch naming conventions:**
Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`.
- `feature/description` - for new features
- `fix/description` - for bug fixes
- `docs/description` - for documentation updates
- `refactor/description` - for code refactoring
- `test/description` - for adding or updating tests
### 3. Make Your Changes
- Write clean, readable code that follows our coding standards (see below)
- Add tests for new functionality
- Update documentation as needed
- Make sure your changes don't break existing functionality
### 4. Commit Your Changes
We follow conventional commit standards. Format your commit messages as:
```
type(scope): description
[optional body]
[optional footer]
```
**Types:**
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that do not affect the meaning of the code
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `test`: Adding missing tests or correcting existing tests
- `chore`: Changes to the build process or auxiliary tools
**Examples:**
Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a
`commit-msg` hook:
```bash
git commit -m "feat(api): add new dialectic endpoint for user insights"
git commit -m "fix(db): resolve connection pool timeout issue"
git commit -m "docs(readme): update installation instructions"
```
### 5. Submit a Pull Request
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
1. Push your branch to your fork:
### Pre-commit hooks
```bash
git push origin your-branch-name
```
2. Create a pull request on GitHub from your branch to the `main` branch
3. Fill out the pull request template with:
- A clear description of what changes you've made
- The motivation for the changes
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
- Screenshots or examples if applicable
## Pre-commit Hooks
Honcho uses pre-commit hooks to enforce code quality and consistency. They run linting, formatting, type checking, and security scans before each commit.
### Installation
Install them. CI runs the same checks, and it is much faster to find out locally.
```bash
uv add --dev pre-commit
uv run pre-commit install \
--hook-type pre-commit \
--hook-type commit-msg \
--hook-type pre-push
```
### What the hooks do
At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit,
markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and
the SDK builds.
- **Code Quality** — Python linting and formatting (ruff), TypeScript linting (biome)
- **Type Checking** — Static analysis with basedpyright
- **Security** — Vulnerability scanning with bandit
- **Documentation** — Markdown linting and license header checks
- **Testing** — Automated test runs for Python and TypeScript
- **File Hygiene** — Trailing whitespace, line endings, file size checks
- **Commit Standards** — Conventional commit message validation
That split matters — **a clean commit is not a clean push.** The test suite only runs at
`pre-push`, so the first time you see test failures may be well after you thought you were
done.
### Manual execution
Run against all files without committing:
Run them by hand at any time:
```bash
uv run pre-commit run --all-files
uv run pre-commit run ruff --all-files
```
Run a specific hook:
Or the individual tools:
```bash
uv run pre-commit run ruff --all-files
uv run pre-commit run basedpyright --all-files
uv run ruff check src/
uv run ruff format src/
uv run basedpyright
```
## Coding Standards
### Tests
### Python Code Style
Write tests for new functionality, in the directory under `tests/` that mirrors the code you
changed. Which layer you need depends on what you touched:
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
- Use [ruff](https://docs.astral.sh/ruff/) for linting and code formatting
- Use type hints where possible
- Write docstrings for functions and classes using Google style docstrings
| What you changed | What to run |
| --- | --- |
| Anything in `src/` | Unit tests in the matching `tests/` tree — `uv run pytest tests/...` |
| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) |
| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` |
| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it — not just pytest |
| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file |
### Code Organization
- Keep functions focused and single-purpose
- Use meaningful variable and function names
- Add comments for complex logic
- Follow existing patterns in the codebase
### Testing
- Write unit tests for new functionality
- Ensure existing tests pass before submitting
- Use descriptive test names that explain what is being tested
- Mock external dependencies appropriately
The TypeScript SDK tests need a running server with a database and Redis, which pytest
orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root —
`bun test` on its own will fail. To type-check the SDK alone:
`cd sdks/typescript && bun run tsc --noEmit`.
### Documentation
- Update relevant documentation for new features
- Include examples in docstrings where helpful
- Keep README and other docs up to date with changes
Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports,
or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry
in `docs/docs.json` or they will not appear in the nav.
## Review Process
## Opening the pull request
1. **Automated checks** - Your PR will run through automated checks including tests and linting
2. **Project maintainer review** - A project maintainer will review your code for:
- Code quality and adherence to standards
- Functionality and correctness
- Test coverage
- Documentation completeness
3. **Discussion and iteration** - You may be asked to make changes or clarifications
4. **Approval and merge** - Once approved, your PR will be merged into `main`
### Leave "Allow edits by maintainers" checked
## Types of Contributions
This is the single most useful thing you can do to get your PR merged quickly.
We welcome various types of contributions:
Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix.
If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a
review comment, and then we wait — sometimes for weeks — for a round trip on a two-line
change.
- **Bug fixes** - Help us squash bugs and improve stability
- **New features** - Add functionality that benefits the community
- **Documentation** - Improve or expand our documentation
- **Tests** - Increase test coverage and reliability
- **Performance improvements** - Help make Honcho faster and more efficient
- **Examples and tutorials** - Help other developers use Honcho
GitHub checks the box by default when you fork. Leave it checked.
## Issue Reporting
One caveat worth knowing: **the option does not exist on forks owned by an organization.**
If you have the choice, fork from your personal account.
When reporting bugs or requesting features:
### Fill out the template
1. Check if the issue already exists
2. Use the appropriate issue template
3. Provide clear reproduction steps for bugs
4. Include relevant environment information
5. Be specific about expected vs actual behavior
`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox.
## Questions and Support
"Proofs" means evidence the change works: the command you ran and its result, a log snippet,
a screenshot, the failing case before and after. This is the section that most determines
how fast your PR gets reviewed. Do not add sections to the template.
- **General questions** - Join our [Discord](http://discord.gg/honcho)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue
Link the issue so the gate can see it: `Fixes #123` in the description, or the
**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so
either route works — but a bare `#123` mention is only a reference and does not count.
### Review
1. Automated checks run — tests, linting, static analysis, and the issue gate.
2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code.
`.github/CODEOWNERS` routes the request to whoever owns the area you touched.
3. You may be asked for changes. Or we may just push them, if you left edits enabled.
4. Once approved, we merge to `main`.
If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho).
## Reporting bugs and requesting features
Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is
one per kind of report, and picking the right one is most of what gets an issue triaged
quickly:
- **Bug report** — something is broken or behaves incorrectly
- **Memory / recall quality** — the deriver or dialectic returns poor, wrong, or missing context
- **Feature request** — a new capability or API surface
- **Integration request** — plugins, framework integrations, app-store listings
- **Documentation issue** — anything wrong or missing in the docs
- **General questions** — not an issue at all; ask in [Discord](https://discord.gg/honcho)
Before opening one, search existing issues, including closed ones.
A good bug report has the Honcho version or commit, whether you are self-hosted or on
`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the
deriver, logs from the worker process are usually the thing we ask for first.
**Redact before you post.** Issues are public, and Honcho stores conversational data — strip
API keys, JWTs, and production user content out of any log or payload you attach.
## Security
Do not open a public issue for a suspected vulnerability. Report it privately through
[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new),
which is the preferred channel, or by email. See [SECURITY.md](./SECURITY.md) for what to
include, and note that Honcho does not operate a bug bounty.
## License
By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./LICENSE) that covers the project.
By contributing to Honcho, you agree that your contributions will be licensed under the same
[AGPL-3.0 License](./LICENSE) that covers the project.
Thank you for helping make Honcho better! 🫡

View File

@ -63,6 +63,9 @@ COPY --chown=app:app migrations/ /app/migrations/
COPY --chown=app:app scripts/ /app/scripts/
COPY --chown=app:app docker/ /app/docker/
COPY --chown=app:app alembic.ini /app/alembic.ini
# src/_version.py reads the service version from here at runtime, so this
# is a runtime input as well as a build input.
COPY --chown=app:app pyproject.toml /app/pyproject.toml
# Copy config files - this will copy config.toml if it exists, and config.toml.example
COPY --chown=app:app config.toml* /app/

140
README.md
View File

@ -8,14 +8,15 @@
---
![Static Badge](https://img.shields.io/badge/Server-3.0.9-blue)
![Static Badge](https://img.shields.io/badge/Server-3.1.0-blue)
[![PyPI version](https://img.shields.io/pypi/v/honcho-ai.svg)](https://pypi.org/project/honcho-ai/)
[![NPM version](https://img.shields.io/npm/v/@honcho-ai/sdk.svg)](https://npmjs.org/package/@honcho-ai/sdk)
[![CLI](https://img.shields.io/pypi/v/honcho-cli.svg?label=honcho-cli)](https://pypi.org/project/honcho-cli/)
[![Discord](https://img.shields.io/discord/1016845111637839922?style=flat&logo=discord&logoColor=23ffffff&label=Plastic%20Labs&labelColor=235865F2)](https://discord.gg/honcho)
**Honcho is memory infrastructure for building stateful agents that understand changing people, agents, groups, projects, and ideas over time.**
Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev) or self-host the FastAPI server yourself.
Store messages and events, let Honcho reason in the background, then query peer representations, session context, search results, or natural-language insights from any model or framework. Use it managed at [api.honcho.dev](https://api.honcho.dev), run a local stack with [`honcho start`](#cli), or self-host the FastAPI server yourself.
Using Honcho as your memory system will earn your agents higher retention, more trust, and help you build data moats to out-compete incumbents.
@ -29,6 +30,7 @@ Using Honcho as your memory system will earn your agents higher retention, more
- [Quickstart](#quickstart)
- [What Honcho Gives You](#what-honcho-gives-you)
- [Integrations](#integrations)
- [CLI](#cli)
- [Core Concepts](#core-concepts)
- [Benchmarks & Evals](#benchmarks--evals)
- [Self-hosting](#self-hosting)
@ -39,7 +41,7 @@ Using Honcho as your memory system will earn your agents higher retention, more
- [Contributing](#contributing)
- [License](#license)
The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory.
The Honcho project is split between several repositories, with this one hosting the core service logic — implemented as a FastAPI server. Client SDKs for Python and TypeScript live in the [`sdks/`](./sdks) directory. The [`honcho-cli`](./honcho-cli) package lives here too.
## Start Here
@ -47,7 +49,9 @@ The Honcho project is split between several repositories, with this one hosting
| -------------------------------------- | ---------------------------------------------------------- | ----------------------------- |
| Give my coding agent persistent memory | Claude Code, OpenCode, OpenClaw, Hermes, or any MCP client | [Integrations](#integrations) |
| Add memory to my product | Python or TypeScript SDK | [Quickstart](#quickstart) |
| Self-host Honcho | Docker / local development | [Self-hosting](#self-hosting) |
| Run Honcho locally | Install CLI, then `honcho start --setup` | [CLI](#cli) |
| Inspect a deployment | `honcho workspace inspect`, `honcho doctor` | [CLI](#cli) |
| Self-host from source | Docker Compose or local development | [Self-hosting](#self-hosting) |
## Why Honcho
@ -56,7 +60,7 @@ The Honcho project is split between several repositories, with this one hosting
| Reasoning-first memory | Extracts conclusions from conversations and events, not just matching chunks. |
| Peer-centric model | Tracks users, agents, groups, projects, and ideas as entities that change over time. |
| Multi-peer perspective | Models what one peer knows about another when configured. |
| Managed or self-hosted | Use `api.honcho.dev` or run the FastAPI server yourself. |
| Managed or self-hosted | Use `api.honcho.dev`, `honcho start` locally, or run the FastAPI server yourself. |
| Agent-tool integrations | MCP, Claude Code, OpenCode, OpenClaw, Hermes, Cursor-compatible clients. |
## The Honcho Loop
@ -70,7 +74,7 @@ Concretely: workspaces hold peers, peers participate in sessions, messages live
## Quickstart
Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or [self-host](#self-hosting) and run against `http://localhost:8000`.
Get an API key at [app.honcho.dev](https://app.honcho.dev) — when you sign up you'll be prompted to join an organization, which gets its own dedicated Honcho instance and $100 free credits. Or install the CLI and run [`honcho start --setup`](#cli), then point the SDK at `http://localhost:8000`.
### Python
@ -226,12 +230,27 @@ For wiring the Honcho SDK into an existing application, install the integration
npx skills add plastic-labs/honcho
```
Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills — `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting and debugging a deployment). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding).
Then invoke `/honcho-integration` in Claude Code (or `/honcho-dev:integrate` via the plugin marketplace). The same command also installs the memory skills — `honcho-memory` (concepts: the recall/record loop, session and peer strategy, plus how to connect and drive an MCP-connected Honcho) and `honcho-cli` (inspecting a deployment, or running a local stack with `honcho start`). Details: [agentic development guide](https://honcho.dev/docs/v3/documentation/introduction/vibecoding).
### Other MCP clients
The same `claude mcp add` form (or its client-specific equivalent) works in any MCP-compatible client. See [MCP guide](https://honcho.dev/docs/v3/guides/integrations/mcp).
## CLI
[`honcho-cli`](https://pypi.org/project/honcho-cli/) inspects a Honcho deployment from the terminal, or runs a personal local stack with Docker.
```bash
uv tool install honcho-cli
honcho init # Honcho API key or browser login + server URL
honcho start --setup basic # local stack: LLM provider key + Docker
honcho doctor
```
`honcho init` authenticates the CLI against a Honcho server. `honcho start --setup` is a separate step: it writes the LLM provider key the local deriver needs and starts API + deriver + Postgres + Redis.
Full commands and local-stack details: [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) · [`honcho-cli/README.md`](./honcho-cli/README.md). To develop the server from source, see [Self-hosting](#self-hosting).
## Core Concepts
Honcho organises everything around **peers** — humans and AI agents alike are first-class entities. The peer model enables:
@ -246,6 +265,7 @@ Peers exchange messages within sessions; Honcho reasons over those messages to b
- **Workspace** (formerly App): top-level container; isolates data between use cases.
- **Peer** (formerly User): any participant — human user or AI agent.
- **Session**: a conversation context; many-to-many with peers.
- **Scope**: a named grouping of sessions that bounds recall (chat, representation, search) to those members.
- **Message**: an atomic data unit (peer-to-peer communication or ingested document chunk).
What you query out of Honcho:
@ -274,9 +294,9 @@ Honcho's evals span LongMemEval, LoCoMo, and other long-conversation benchmarks.
## Self-hosting
Honcho is open source under AGPL-3.0. You can run the full server locally with Docker, then point the SDKs at `http://localhost:8000`.
Honcho is open source under AGPL-3.0. To **run** a personal instance, install the CLI (`uv tool install honcho-cli`) and then [`honcho start --setup`](#cli). The paths below are for building from source, contributing, or deploying without the CLI.
### Quick start (Docker)
### Quick start (from source, Docker)
```bash
git clone https://github.com/plastic-labs/honcho.git
@ -458,79 +478,19 @@ Contributors: see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for pre-commit setup. D
Honcho uses a flexible configuration system that supports both TOML files and environment variables. Configuration values are loaded in priority order: **environment variables > `.env` file > `config.toml` > defaults**.
<!-- markdownlint-disable MD033 -->
<details>
<summary>Full configuration reference</summary>
### Using config.toml
Copy the example configuration file to get started:
Copy the example file to get started:
```bash
cp config.toml.example config.toml
```
Then modify the values as needed. The TOML file is organized into sections:
The file is organized by subsystem — `[app]`, `[db]`, `[auth]`, `[cache]`, `[llm]`, `[deriver]`, `[dialectic]`, `[summary]`, `[dream]`, `[peer_card]`, `[webhook]`, `[metrics]`, `[telemetry]`, `[vector_store]`, and `[sentry]`. Any value can be overridden by an environment variable named `{SECTION}_{KEY}`, using `__` for nesting (`DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL`), or just `{KEY}` for app-level settings.
- `[app]` - Application-level settings (log level, session limits, embedding settings, namespace)
- `[db]` - Database connection and pool settings
- `[auth]` - Authentication configuration
- `[cache]` - Redis cache configuration
- `[llm]` - LLM provider API keys and general settings
- `[deriver]` - Background worker settings and representation configuration
- `[peer_card]` - Peer card generation settings
- `[dialectic]` - Chat Endpoint configuration with per-level reasoning settings
- `[summary]` - Session summarization settings
- `[dream]` - Dream processing configuration (including specialist models and surprisal settings)
- `[webhook]` - Webhook configuration
- `[metrics]` - Prometheus pull-based metrics
- `[telemetry]` - CloudEvents telemetry for analytics
- `[vector_store]` - Vector store configuration (pgvector, turbopuffer, or lancedb)
- `[sentry]` - Error tracking and monitoring settings
### Using Environment Variables
All configuration values can be overridden using environment variables. The environment variable names follow this pattern:
- `{SECTION}_{KEY}` for top-level section settings
- Use `__` inside `{KEY}` for nested settings
- Just `{KEY}` for app-level settings
Examples:
- `DB_CONNECTION_URI` - Database connection string
- `AUTH_JWT_SECRET` - JWT secret key
- `DERIVER_MODEL_CONFIG__TRANSPORT` - Transport for the background deriver
- `SUMMARY_MODEL_CONFIG__MODEL` - Summary model override
- `DIALECTIC_LEVELS__low__MODEL_CONFIG__MODEL` - Model for low reasoning level
- `LOG_LEVEL` - Application log level
- `METRICS_ENABLED` - Enable Prometheus metrics
- `TELEMETRY_ENABLED` - Enable CloudEvents telemetry
### Example
If you have this in `config.toml`:
```toml
[db]
CONNECTION_URI = "postgresql+psycopg://localhost/honcho_dev"
POOL_SIZE = 10
```
You can override just the connection URI in production:
```bash
export DB_CONNECTION_URI="postgresql+psycopg://prod-server/honcho_prod"
```
The application will use the production connection URI while keeping the pool size from config.toml.
</details>
<!-- markdownlint-enable MD033 -->
See the [configuration reference](https://honcho.dev/docs/v3/contributing/configuration) for every available option, and [`.env.template`](./.env.template) for an annotated list of environment variables.
## Architecture
Honcho splits into two services: **Storage** (workspaces, peers, sessions, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process.
Honcho splits into two services: **Storage** (workspaces, peers, sessions, scopes, messages, internal collections) and **Insights** (reasoning, conclusions, representations, summaries, the chat endpoint). Storage is synchronous via the API; Insights is asynchronous via a background queue consumed by the deriver worker process.
**Key features:**
@ -558,16 +518,18 @@ Workspaces
│ ├── Sessions │
│ └── (internal collections, keyed by observer/observed peer pair)
│ │
├── Scopes ←─────────────────┤ (many-to-many with sessions)
│ │
└── Sessions ←───────────────┤ (many-to-many)
└── Sessions ←───────────────┤ (many-to-many with peers)
├── Peers ───────────────┘
└── Messages (session-level)
```
**Relationship Details:**
- A **Workspace** contains multiple **Peers**.
- A **Workspace** contains multiple **Peers** and **Scopes**.
- **Peers** and **Sessions** have a many-to-many relationship (peers can participate in multiple sessions, sessions can have multiple peers).
- **Scopes** and **Sessions** have a many-to-many relationship (a session can belong to several scopes; a scope groups many sessions).
- **Messages** belong to a session and are labelled by their source peer.
- **Internal collections** of vector-embedded **documents** are keyed by `(observer, observed)` peer pairs. They are not directly exposed via the API; the observations stored in them are exposed as **Conclusions**.
@ -591,6 +553,28 @@ This unified model enables complex multi-participant interactions.
The `Session` object represents a set of interactions between `Peers` within a
`Workspace`. Other applications may refer to this as a thread or conversation.
Sessions can involve multiple peers with configurable observation settings.
A session can optionally join one or more **Scopes** at creation, or later via
the scopes API.
#### Scopes
A `Scope` is a named grouping of sessions inside a `Workspace`. It is a
visibility boundary on recall: chat, representation, session context, and
workspace search answered through a scope see only what happened in that
scope's member sessions. The underlying peers keep their unified
representations across everything they have participated in.
Developers manage scopes through the scopes API (`honcho.scope(...)` /
`honcho.scopes()`) and an optional `scopes` field on session create — not
through observer/observed configuration. Adding a session that already has
messages copies its existing explicit conclusions into the scope (no
re-derivation); removing one reconciles those copies back out. Query
backfill progress with the scope `status` endpoint.
A single scope name answers from that scope's collection and card. A list of
scopes restricts recall to the union of their member sessions. Empty scopes
fail closed. `scope` is mutually exclusive with `session` / `filters` on the
same read.
#### Messages
@ -668,6 +652,7 @@ For low-latency use cases, Honcho provides access to a `representation` endpoint
- **Python** — [`honcho-ai`](https://pypi.org/project/honcho-ai/) on PyPI · source in [`sdks/python/`](./sdks/python)
- **TypeScript** — [`@honcho-ai/sdk`](https://www.npmjs.com/package/@honcho-ai/sdk) on npm · source in [`sdks/typescript/`](./sdks/typescript)
- **CLI** — [`honcho-cli`](https://pypi.org/project/honcho-cli/) on PyPI · source in [`honcho-cli/`](./honcho-cli) · [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli)
SDKs are versioned independently of the server. Current SDK versions track each other; the server badge above reflects the deployed server version.
@ -676,11 +661,14 @@ See the [SDK Reference](https://honcho.dev/docs/v3/documentation/reference/sdk)
## Learn More
- [Developer documentation](https://honcho.dev/docs/) — full API surface, guides, integrations.
- [CLI reference](https://honcho.dev/docs/v3/documentation/reference/cli) — local stack, inspect/debug commands, scripting.
- [Plastic Labs blog](https://blog.plasticlabs.ai/) — design philosophy and history of the project.
## Contributing
We welcome contributions to Honcho! Please read our [Contributing Guide](./CONTRIBUTING.md) for details on our development process, coding conventions, and how to submit pull requests.
We welcome contributions to Honcho. One thing to know before you start: **pull requests must be linked to an issue carrying the `maintainer-approved` label**, or they are closed automatically. [Browse the approved queue](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved), or make your case in [Discord](http://discord.gg/honcho) — that is where maintainers are most active.
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full process, an architecture walkthrough, and a map of where to change what. For vulnerabilities, see [SECURITY.md](./SECURITY.md) — note that Honcho does not operate a bug bounty.
## License

73
SECURITY.md Normal file
View File

@ -0,0 +1,73 @@
# Security Policy
## Supported Versions
The `main` branch of this repo maps to the latest canary version of Honcho. To see which versions are supported please refer to the git tags in the repo or the [compatibility guide](https://honcho.dev/docs/changelog/compatibility-guide).
## Reporting a Vulnerability
Do not open a public issue for a suspected vulnerability. Report it privately through one of:
1. **[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new)** — preferred; it keeps the report, our replies, and any fix coordinated in one place.
2. Email [support@honcho.dev](mailto:support@honcho.dev) with `[SECURITY]` in the subject.
Include as much of the following as you have:
- **Version** — a git commit SHA, or the release tag you are running
- **Deployment** — self-hosted or the managed service at `api.honcho.dev`
- **Affected component** — API, deriver, dialectic, auth/JWT, an SDK, or the managed offering
- **Reproduction** — the exact steps, requests, or script that trigger it
- **Proof of concept** — the smallest thing that demonstrates the issue actually works
- **Impact** — what an attacker gains, and what they need to already have to get it
- **How you found it** — manual review, fuzzing, a scanner, or model-assisted analysis
Reports with a working proof of concept get looked at first. A report that only describes a
theoretical problem is much slower for us to act on, because we have to build the repro
ourselves before we can confirm anything.
Honcho stores conversational data and peer representations. **Do not attach production user
content, API keys, or JWTs** to a report — if we need a sample, we will ask for a redacted
one.
## Testing
Test against an instance you operate. Do not run security testing against `api.honcho.dev`
or against any Honcho deployment that is not yours — self-hosting is a first-class path and
takes a few minutes to set up — install the CLI (`uv tool install honcho-cli`) then run `honcho start --setup` (Docker + an LLM provider key), or see [Self-hosting](./README.md#self-hosting).
## What to Expect
We will acknowledge your report and tell you whether we consider it in scope. If it is, we
will let you know when a fix ships.
We do not commit to a response SLA, we do not coordinate CVE assignment on request, and we
do not operate a disclosure timeline you can hold us to. This is a small team.
## Out of Scope
The following are not treated as vulnerabilities. Reports consisting only of these will be
closed without a detailed response:
- Automated scanner output with no working proof of concept
- Model-generated findings that have not been verified by a human against a running instance
- Missing security headers or TLS configuration with no demonstrated exploit
- Rate limiting, or resource exhaustion with no demonstrated impact beyond your own instance
- Vulnerabilities in dependencies with no demonstrated exploit path through Honcho
- Configuration weaknesses that require an already-compromised host, or that come from
deliberately insecure settings (for example running with `AUTH_USE_AUTH=false`, which is
the documented local-development default and is not intended for a public deployment)
- Social engineering, phishing, and physical access
For ordinary bugs, memory or recall quality problems, and feature requests, use the
[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose) instead.
## No Bug Bounty
The Honcho project does not offer any rewards for reported bugs or
vulnerabilities. We do not aid security researchers to get such rewards for
Honcho problems from other sources.
A bug bounty gives people too strong incentives to find and make up "problems"
in bad faith that cause overload and abuse.
We still appreciate and value valid vulnerability reports.

View File

@ -10,14 +10,14 @@ This guide helps you match the right SDK version to your Honcho API version. New
<CardGroup cols={2}>
<Card title="TypeScript SDK" icon="js">
**Latest:** v2.1.2
**Latest:** v2.4.0
```bash
npm install @honcho-ai/sdk
```
</Card>
<Card title="Python SDK" icon="python">
**Latest:** v2.1.2
**Latest:** v2.4.0
```bash
pip install honcho-ai
@ -30,7 +30,9 @@ This guide helps you match the right SDK version to your Honcho API version. New
| Honcho API Version | TypeScript SDK | Python SDK |
|-------------------|---------------|------------|
| v3.0.11 (Current) | v2.1.2 | v2.1.2 |
| v3.1.0 (Current) | v2.4.0 | v2.4.0 |
| v3.0.12 | v2.3.0 | v2.3.0 |
| v3.0.11 | v2.1.2 | v2.1.2 |
| v3.0.10 | v2.1.2 | v2.1.2 |
| v3.0.9 | v2.1.2 | v2.1.2 |
| v3.0.8 | v2.1.2 | v2.1.2 |

View File

@ -27,7 +27,45 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
### Honcho API and SDK Changelogs
<Tabs>
<Tab title="Honcho API">
<Update label="v3.0.12 (Current)">
<Update label="v3.1.0 (Current)">
### Added
- Scopes: a named grouping of sessions that acts as a visibility boundary on recall, implemented as a facade over an observer peer (`scope.{name}` with `{"kind": "scope"}`). Developers manage them exclusively through `/v3/workspaces/{workspace_id}/scopes` (create-or-get, list, get, add/list/remove session membership) and an optional `scopes` field on session create — never through the observer/observed mechanics. Scope peers cannot author messages, cannot be a chat or representation `target`, are excluded from `peers.list` by default (`PeerGet.kind` = `"scope"` / `"all"` switches the view), and are rejected on the generic session-peer routes. Workspace-level key required; peer- and session-scoped keys get 401. Legacy peers occupying a reserved `scope.` name without the kind flag are refused with 409, never adopted (#884)
- `scope` read option on chat, representation, session context, and workspace search. A single scope swaps the observer to the backing scope peer so conclusion recall, peer cards, and message tools stay inside that scope's membership. A list of scopes takes the union of member sessions (capped at `MAX_SESSION_ALLOWLIST_ENTRIES`) and executes via the session-allowlist path. Empty scopes fail closed. `scope` is mutually exclusive with `filters` and `session_id`. Workspace- or admin-level key required (403 otherwise). Scope peers are also rejected as `peer_target` / `peer_perspective` on session context and as the path peer or `target` on `GET /peers/{id}/context` (#897)
- Scope backfill-by-copy and removal reconciliation. Adding a session that already has messages copies its explicit-level documents into the scope's collections (no LLM re-derivation; idempotent via `copied_from`). Removing a session soft-deletes those copies and fail-closed cascades to derived documents whose `source_ids` intersect anything removed, then enqueues a `card_refresh` dream with `rebuild=True` plus an omni dream. `GET /v3/workspaces/{workspace_id}/scopes/{scope_id}/status` reports per-session backfill state (`pending` / `completed` / `failed`, plus `docs_copied`) (#904)
- Workspace-level chat at `POST /v3/workspaces/{workspace_id}/chat`: agentic dialectic over the whole workspace instead of a single (observer, observed) pair. Prefetches workspace stats and the top active peers' self cards, then searches pair-scoped memory with `[observer->observed]` attribution. Supports `session_id`, `scope`, `reasoning_level`, `response_format`, and SSE streaming (#931)
- MCP workspace discovery: tools accept `workspace_id`, the worker honors an optional `X-Honcho-Workspace-ID` connection header, and `list_workspace` / `create_workspace` tools let clients pick or create a workspace instead of relying on the SDK default (#1020)
- MCP `search` also queries conclusions in parallel with messages when `peer_id` is given, returning `{messages, conclusions}`. The conclusions leg degrades to `[]` on error so search never gets worse than before (#974)
- Prometheus metrics for physical DB connections, visible even under `DB_POOL_CLASS=null`: `db_connections_open` (gauge) and `db_connections_established` (counter), hooked to SQLAlchemy connection-lifecycle events and registered on both the API and the deriver (#1055)
- Bounded-label Prometheus series are zero-initialized at process start so an absent series means a broken scrape rather than "nothing happened" (#927)
### Changed
- Workspace and pair chat system prompts now describe Honcho, peers, and the harness on their own terms, and render only the tools the request actually offers. The pair prompt no longer advertises a write tool that is not in the loadout (#1066)
- Deriver idle polling backoff is longer and no longer reset by periodic reconciler work, so downstream connection pools can cull idle DB connections (#1015)
- LLM provider SDKs are lazy-loaded so idle API and deriver processes no longer pay for every provider at import time (#1011)
- Production image is a multi-stage build: LanceDB/PyArrow move behind an optional `lancedb` extra (`INSTALL_LANCEDB=true` to restore them), FastAPI's unused cloud CLI is dropped, and the venv is copied into the runtime image with final ownership so Docker does not double the layer. Default unpacked image is about 663 MB (was 1.7 GB) (#1014)
- Redis Cluster cache keys hash-tag the namespace so one deployment's keys land on a single shard instead of opening a connection to every node. No behaviour change on a non-cluster backend; existing keys age out by TTL (#1058)
- Deriver extraction prompt no longer leaks its own few-shot examples into extracted conclusions (#1028)
### Fixed
- Observer-scoped `get_observation_context` no longer materializes every session the observer has ever joined into a `session_name IN (...)` list (twice in one statement). Past ~32k sessions that hit psycopg's bind-parameter ceiling and 500'd. The observer half is now a correlated `EXISTS` over `session_peers`, two bind parameters regardless of membership size (#1065)
- Re-adding an already-active session peer no longer advances `joined_at`, so `peer_perspective` search keeps messages from the original join. Genuine leave-and-rejoin still starts a new window (#1059)
- Transient embedding-provider errors (for example an OpenAI-compatible 200 with empty `data: []`) were relabeled as token-limit errors. Only genuine oversize input raises `EmbeddingTokenLimitError`; other provider errors propagate unchanged (#791)
- The filter DSL now fails closed with a 422 instead of a 500 on bad shapes, coerces operands by column type (so `{"session_id": {"ne": "abc"}}` is a string inequality rather than "invalid numeric"), and treats `NOT` / `ne` as null-safe (`IS NOT TRUE` / `IS DISTINCT FROM`) so negation no longer drops rows whose field is unset. Closed-set columns like `level` reject unknown values. Session-allowlist entries must be well-formed ids (`*` is 422, not a silent widen) (#947)
- `ne` on JSONB metadata keys is null-safe: a missing key is not equal to the compared value, so `{"metadata": {"foo": {"ne": "bar"}}}` includes rows where `foo` is unset (#1036)
- Oversized texts in `simple_batch_embed` are truncated to the embedding token cap instead of failing the whole batch. Representation processing reports failed observer saves in `RepresentationCompletedEvent` and raises when every observer save fails (#1019)
- Assistant `reasoning_content` (DeepSeek / some OpenRouter models) is preserved across tool-loop turns. Previously the tool loop dropped thinking content before building the next assistant history message, so continuation requests failed. `reasoning_details` still takes precedence when both are present (#1034)
- `create_observations` now honors `DERIVER_DEDUPLICATE` instead of hardcoding `deduplicate=True`, matching the representation write path (#1018)
- `provider_params.timeout` is forwarded to the OpenAI-compatible embedding client, not just the LLM client (#1024)
- Conclusions semantic-search validation errors name the field and the constraint instead of returning a generic 422 (#960)
- OpenAI-compatible embedding calls request `encoding_format=float` so providers that default to base64 do not break pgvector inserts (#938)
- Gemini batch embedding works for `gemini-embedding-2*` models, which rejected the previous request shape (#745)
- MCP OAuth with no advertised scopes no longer defaults to read-only (which 403'd chat and search POSTs). Protected-resource metadata advertises read and write (#1004)
</Update>
<Update label="v3.0.12">
### Added
- Session allowlist on the Dialectic and representation via a constrained `filters` body on `POST /peers/{peer_id}/chat` and `/representation`, supporting only the `session_id` key (a session id, a bare list, or `{"in": [...]}`). Unsupported keys and shapes are rejected with 422 rather than silently ignored, it composes with `session_id` (which must be included in the allowlist when both are given), and it is capped at 1,000 sessions per request. Enforcement is uniform and fail-closed at every recall chokepoint: scoped conclusion recall is restricted to `level == "explicit"` (dream-derived conclusions carry a single `session_name` but are synthesized across all sessions, so that stamp can't be scoped on), `get_reasoning_chain` is unavailable under an allowlist, and an empty allowlist short-circuits to empty results everywhere. Workspace keys pass the allowlist as-given; peer-scoped JWTs must be an active member of every allowlisted session (401 otherwise) (#882)
@ -747,6 +785,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
<Tab title="Python SDK">
[Python SDK](https://pypi.org/project/honcho-ai/)
<Update label="v2.4.0 (Current)">
### Added
- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
- `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
- Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
### Changed
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
</Update>
<Update label="v2.3.0">
### Added
@ -915,6 +964,17 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
<Tab title="TypeScript SDK">
[TypeScript SDK](https://www.npmjs.com/package/@honcho-ai/sdk)
<Update label="v2.4.0 (Current)">
### Added
- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
- `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
- Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
### Changed
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
</Update>
<Update label="v2.3.0">
### Added
@ -1110,6 +1170,26 @@ Welcome to the Honcho changelog! This section documents all notable changes to t
</Tab>
<Tab title="Honcho CLI">
[Honcho CLI](https://pypi.org/project/honcho-cli/)
<Update label="v0.1.4 (Current)">
### Added
- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK`
### Fixed
- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068)
- `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field
</Update>
<Update label="v0.1.3">
### Added
- `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029)
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006)
### Fixed
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006)
</Update>
<Update label="v0.1.2">
### Added

View File

@ -24,7 +24,7 @@
"navigation": {
"versions": [
{
"version": "v3.0.12",
"version": "v3.1.0",
"api": {
"openapi": ["v3/openapi.json"]
},

View File

@ -505,6 +505,69 @@ honcho session view [<session_id>]
</Accordion>
</AccordionGroup>
## honcho start
Start a local Honcho stack (API, deriver, Postgres, Redis).
Requires Docker. Uses cloud LLM providers. Does not change the CLI's
configured server URL — pass HONCHO_BASE_URL to talk to this stack.
``--setup basic`` or ``--setup advanced`` runs an interactive config wizard.
```bash
honcho start
```
<ParamField path="--profile" type="string" default="local">
Local stack profile name.
</ParamField>
<ParamField path="--api-port" type="string">
Host port for the API.
</ParamField>
<ParamField path="--db-port" type="string">
Host port for Postgres.
</ParamField>
<ParamField path="--redis-port" type="string">
Host port for Redis.
</ParamField>
<ParamField path="--setup" type="string">
Interactive config wizard: basic (provider/model) or advanced (embeddings, deriver, dialectic, dreams, flush).
</ParamField>
<ParamField path="--image" type="string">
Honcho image to pull and pin by digest (default: ghcr.io/plastic-labs/honcho:latest).
</ParamField>
<ParamField path="--timeout" type="string" default="180">
Seconds to wait for /health after compose up.
</ParamField>
## honcho status
Show local stack endpoints and container health.
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
```bash
honcho status
```
<ParamField path="--profile" type="string">
Limit to this profile. Omit to show every local stack.
</ParamField>
## honcho stop
Stop the local stack started by `honcho start`. Keeps data unless --wipe.
```bash
honcho stop
```
<ParamField path="--profile" type="string" default="local">
Local stack profile name.
</ParamField>
<ParamField path="--wipe" type="boolean">
Also delete volumes (Postgres data).
</ParamField>
## honcho workspace
List, create, inspect, delete, and search workspaces.

View File

@ -5,13 +5,31 @@ icon: 'handshake'
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
## Before you write code
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
A pull request that is not linked to an approved issue gets labelled `needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one before it is closed automatically. Reopening costs nothing once the link is in place. This is automated. We do this because an unreviewable backlog helps nobody: a PR against an unapproved issue is work you did that we may not be able to merge, no matter how good it is.
So, in order:
1. **Find approved work.** Browse [issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved). That label is the queue of things we have agreed should be built. Anything in it is fair game — comment on the issue to claim it.
2. **Or open an issue and get it approved.** Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers triage and apply the label.
3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).** This is the fastest path by a wide margin. Maintainers are more active there than in the issue tracker, and a five-minute conversation about what you want to build usually resolves whether it fits before either side spends real time on it.
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or **Development → link an issue** in the sidebar. Both work.
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the issue linkage.
## Getting Started
Before you start contributing, please:
1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally.
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
2. **Join our community** - Feel free to join us in our [Discord](https://discord.gg/honcho) to discuss your changes, get help, or ask questions.
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
@ -94,7 +112,7 @@ git commit -m "docs(readme): update installation instructions"
3. Fill out the pull request template with:
- A clear description of what changes you've made
- The motivation for the changes
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
- A link to the approved issue — `Fixes #123` in the description, or **Development → link an issue** in the sidebar. This is required; see [Before you write code](#before-you-write-code).
- Screenshots or examples if applicable
## Coding Standards
@ -128,7 +146,7 @@ git commit -m "docs(readme): update installation instructions"
## Review Process
1. **Automated checks** - Your PR will run through automated checks including tests and linting
1. **Automated checks** - Your PR will run through automated checks including tests, linting, and the issue gate
2. **Project maintainer review** - A project maintainer will review your code for:
- Code quality and adherence to standards
- Functionality and correctness
@ -153,17 +171,21 @@ We welcome various types of contributions:
When reporting bugs or requesting features:
1. Check if the issue already exists
2. Use the appropriate issue template
2. Use the appropriate [issue template](https://github.com/plastic-labs/honcho/issues/new/choose) (bug, memory/recall quality, feature, integration, or documentation)
3. Provide clear reproduction steps for bugs
4. Include relevant environment information
4. Include relevant environment information (managed vs self-hosted, server version, SDK)
5. Be specific about expected vs actual behavior
6. Redact secrets, JWTs, and production user content
## Questions and Support
- **General questions** - Join our [Discord](http://discord.gg/honcho)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue
- **General questions** - Join our [Discord](https://discord.gg/honcho)
- **Bug reports** - GitHub issues → Bug report template
- **Memory / recall quality** - GitHub issues → Memory / recall quality template
- **Feature requests** - GitHub issues → Feature request template
- **Integrations / plugins / app-store listings** - GitHub issues → Integration request template
- **Documentation issues** - GitHub issues → Documentation issue template
- **Security issues** - Report **privately** only — see [`SECURITY.md`](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) (GitHub Private Vulnerability Reporting or email). Do not open a public issue.
## License

View File

@ -3,170 +3,360 @@ title: 'Contributing Guidelines'
icon: 'handshake'
---
Thank you for your interest in contributing to Honcho! This guide outlines the process for contributing to the project and our development conventions.
{/* This file mirrors CONTRIBUTING.md in the repo root. Update both. */}
## Getting Started
Thanks for your interest in contributing. This guide covers how work gets accepted, how
Honcho is put together, and what a mergeable pull request looks like.
Before you start contributing, please:
Honcho is a small team maintaining a project that gets more proposals than we can review.
The rules below exist so that the work you do has somewhere to land — not to keep you out.
1. **Set up your development environment** - Follow the [Local Development guide](https://github.com/plastic-labs/honcho/blob/main/CONTRIBUTING.md#local-development) in the Honcho repository to get Honcho running locally.
## Before you write code
2. **Join our community** - Feel free to join us in our [Discord](http://discord.gg/honcho) to discuss your changes, get help, or ask questions.
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
3. **Review existing issues** - Check the [issues tab](https://github.com/plastic-labs/honcho/issues) to see what's already being worked on or to find something to contribute to.
A pull request that is not linked to an approved issue gets labelled
`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one
before it is closed automatically. Reopening costs nothing once the link is in place. This
is automated. We do this because an unreviewable backlog helps nobody: a PR against an
unapproved issue is work you did that we may not be able to merge, no matter how good it
is.
## Contribution Workflow
So, in order:
### 1. Fork and Clone
1. **Find approved work.** Browse
[issues labelled `maintainer-approved`](https://github.com/plastic-labs/honcho/issues?q=is%3Aissue+is%3Aopen+label%3Amaintainer-approved).
That label is the queue of things we have agreed should be built. Anything in it is fair
game — comment on the issue to claim it.
1. Fork the repository on GitHub
2. Clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/honcho.git
cd honcho
```
3. Add the upstream repository as a remote:
```bash
git remote add upstream https://github.com/plastic-labs/honcho.git
```
2. **Or open an issue and get it approved.** Use the
[issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). Maintainers
triage and apply the label.
### 2. Create a Branch
3. **If you feel strongly about an issue, come to [Discord](https://discord.gg/honcho).**
This is the fastest path by a wide margin. Maintainers are more active there than in the
issue tracker, and a five-minute conversation about what you want to build usually
resolves whether it fits before either side spends real time on it.
Create a new branch for your feature or bug fix:
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or
**Development → link an issue** in the sidebar. Both work.
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an
obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the
issue linkage.
## What gets prioritized
Roughly, work on Honcho falls along these axes. Knowing which one your idea sits on tells
you a lot about how likely it is to get approved.
| Axis | What it covers |
| --- | --- |
| **Observability** | Understanding how Honcho behaves in production — telemetry, tracing, CloudEvents, metrics. |
| **Memory quality** | Better conclusions from the same input — the deriver, dreamer, and dialectic; eval results. |
| **Developer experience** | Fitting cleanly into more application architectures — SDKs, scopes, composable peers, the CLI. |
| **Breadth of input** | Widening what Honcho can ingest and represent — multimodal and non-conversational data. |
| **Ubiquity** | Reachable wherever a developer already works — integrations, self-hosting, alternate vector-store and inference backends, local-first defaults. |
| **Reliability and cost** | Trustworthy in production — connection and concurrency hardening, queue throughput, cost per token. |
In practice, **Ubiquity** and **Developer experience** are where outside contributions land
most easily. A new integration, a self-hosting rough edge, a vector-store or inference
backend, an SDK ergonomics fix — these are additive and rarely collide with work already in
flight.
Changes to the reasoning pipeline itself — deriver prompts, dialectic tool design, dreamer
strategy — are the hardest to accept from outside. Not because they are unwelcome, but
because they are measured against eval results we run internally, and they frequently
conflict with in-flight work. Talk to us in Discord first, always.
## If you're an agent
If you are a coding agent working on this repository, read this section before writing code.
The most common failure we see is a well-formed, well-tested pull request against an issue
that was never approved. That gets closed, and the work is wasted.
- **Check the gate first.** Before writing code:
```bash
gh issue view <N> --repo plastic-labs/honcho --json number,title,state,labels
```
Stop if there is no issue number, if the issue is closed, or if `maintainer-approved` is
not in the labels. Report that to the person you are working with instead of proceeding.
- **Do not open a PR in order to establish the issue link afterwards.** The issue comes
first.
- **Do not report checks you did not run.** If you did not execute the test command, say so.
A PR body claiming a green run that did not happen costs a maintainer more time than no
claim at all.
- **Use the checklist.** [`skills/pre-pr/SKILL.md`](https://github.com/plastic-labs/honcho/blob/main/skills/pre-pr/SKILL.md) in this repo
encodes the gate, the test-layer matrix, and the PR body format. If your harness supports
skills, invoke it rather than reimplementing the checks.
## How Honcho works
Enough architecture to find your way around. For the user-facing model — what a Peer is, what
`get_context` returns — see [Core Concepts](https://github.com/plastic-labs/honcho#core-concepts) and the
[documentation](https://honcho.dev/docs/).
### Two processes
Honcho runs as two cooperating processes over a shared Postgres database and Redis cache.
| | API server | Deriver worker |
| --- | --- | --- |
| Start | `uv run fastapi dev src/main.py` | `uv run python -m src.deriver` |
| Entry | `src/main.py` | `src/deriver/__main__.py` |
| Does | Serves HTTP, enqueues background work, returns immediately | Consumes the queue: Deriver, Summarizer, Dreamer, Reconciler |
| Hosts | The Dialectic agent, inline on the request path | Everything else |
The split is the load-bearing design decision: **an HTTP request never blocks on LLM work**,
with the single exception of the Dialectic chat endpoint, which is synchronous by nature.
If you are adding something slow, it belongs in the worker.
The deriver is a separate process. If messages go in and nothing ever comes out, the usual
cause is that nobody started it.
### The path of a message
Worth tracing once, because it crosses most of the codebase:
1. `POST /v3/workspaces/{w}/sessions/{s}/messages` lands in `src/routers/messages.py`.
2. The row is written, then `enqueue()` in `src/deriver/enqueue.py` creates `queue_item`
rows — one set of work per observing peer.
3. `src/deriver/queue_manager.py` polls the queue, claiming work units so that messages in a
session are processed in order.
4. `process_item()` in `src/deriver/consumer.py` dispatches on task type — representation,
summary, deletion, reconciliation.
5. For a representation task, `process_representation_tasks_batch()` in
`src/deriver/deriver.py` makes **one structured-output LLM call for the whole batch** and
writes the resulting conclusions into the collection keyed by the
`(observer, observed)` peer pair.
6. Later, `src/dialectic/` reads those conclusions back at recall time to answer a chat
request.
Embedding is deliberately *not* on this path. `MessageEmbedding` rows are written with
`sync_state='pending'` and embedded asynchronously by the Reconciler
(`src/reconciler/sync_vectors.py`), which runs on a scheduler inside the deriver process.
### The four agents
They share tool definitions in `src/utils/agent_tools.py` and the provider-agnostic LLM
client in `src/llm/`. Each has its own `MODEL_CONFIG` with a fallback chain in
`src/config.py`.
| Agent | Where | Shape |
| --- | --- | --- |
| **Deriver** | `src/deriver/` | A single structured-output call per message batch. Not a tool loop — this is a deliberate cost and latency tradeoff. |
| **Dialectic** | `src/dialectic/` | The one tool-using agent on the request path. Loops over tools until it can answer. Five reasoning tiers from `minimal` to `max`, each with its own model and tool set. |
| **Dreamer** | `src/dreamer/` | Off-queue consolidation. Two specialist phases (deduction, then induction) that build reasoning trees over existing conclusions. |
| **Summarizer** | `src/utils/summarizer.py` | Direct LLM call, no tools. Two tiers — short and long summaries at different message counts. |
Prompts live in `src/deriver/prompts.py`, `src/dialectic/prompts.py`, and
`src/dreamer/specialists.py`.
### A note on naming
What the public API and documentation call **conclusions** are called **observations**
throughout the code — `create_observations`, `get_observation_context`, and so on. Likewise
**collections** and **documents** are internal storage concepts that are not exposed
directly through the API. Do not rename across that boundary in a drive-by change; the
public and internal vocabularies are being reconciled deliberately.
## Where to change what
| I want to change... | Start here |
| --- | --- |
| An HTTP endpoint | `src/routers/` — one module per resource |
| A database query | `src/crud/` — mirrors the router layout |
| The database schema | `src/models.py`, plus a migration in `migrations/versions/` |
| A configuration value | `src/config.py`, and add it to `config.toml.example` and `.env.template` |
| A tool an agent can call | `src/utils/agent_tools.py` — definitions plus the per-agent tool lists |
| A prompt | `src/deriver/prompts.py`, `src/dialectic/prompts.py`, `src/dreamer/specialists.py` |
| LLM provider behavior | `src/llm/backends/` — `anthropic.py`, `gemini.py`, `openai.py` |
| Embeddings or vector storage | `src/embedding_client.py`, `src/vector_store/` |
| Telemetry or metrics | `src/telemetry/` — see the notes in `CLAUDE.md` before adding an event type |
| Authentication and scoping | `src/security.py`, `src/dependencies.py` |
| The Python or TypeScript SDK | `sdks/python/`, `sdks/typescript/` |
| The CLI | `honcho-cli/` |
| The MCP server | `mcp/` |
| Public documentation | `docs/v3/` — Mintlify; nav lives in `docs/docs.json` |
Tests in `tests/` mirror `src/`. `CLAUDE.md` at the repo root has more detail on house
conventions, and is worth skimming even if you are not using an agent.
## Local setup
To run a personal instance, install the CLI (`uv tool install honcho-cli`) and then run
`honcho start --setup` (Docker + an LLM provider key) — [CLI reference](/v3/documentation/reference/cli).
To **develop this repo**, clone it and:
```bash
uv sync # create the venv and install dependencies
uv run alembic upgrade head # apply migrations
```
Run both processes, in separate terminals:
```bash
uv run fastapi dev src/main.py # API server, reloads on change
uv run python -m src.deriver # background worker
```
Everything Python goes through `uv run`. Redis is optional for local development; without it
caching is simply disabled.
## Making the change
### Branches and commits
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix-name
```
**Branch naming conventions:**
- `feature/description` - for new features
- `fix/description` - for bug fixes
- `docs/description` - for documentation updates
- `refactor/description` - for code refactoring
- `test/description` - for adding or updating tests
Prefixes: `feature/`, `fix/`, `docs/`, `refactor/`, `test/`.
### 3. Make Your Changes
Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a
`commit-msg` hook:
- Write clean, readable code that follows our coding standards (see below)
- Add tests for new functionality
- Update documentation as needed
- Make sure your changes don't break existing functionality
### 4. Commit Your Changes
We follow conventional commit standards. Format your commit messages as:
```
type(scope): description
[optional body]
[optional footer]
```
**Types:**
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that do not affect the meaning of the code
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `test`: Adding missing tests or correcting existing tests
- `chore`: Changes to the build process or auxiliary tools
**Examples:**
```bash
git commit -m "feat(api): add new dialectic endpoint for user insights"
git commit -m "fix(db): resolve connection pool timeout issue"
git commit -m "docs(readme): update installation instructions"
```
### 5. Submit a Pull Request
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
1. Push your branch to your fork:
```bash
git push origin your-branch-name
```
### Pre-commit hooks
2. Create a pull request on GitHub from your branch to the `main` branch
Install them. CI runs the same checks, and it is much faster to find out locally.
3. Fill out the pull request template with:
- A clear description of what changes you've made
- The motivation for the changes
- Any relevant issue numbers (use "Closes #123" to auto-close issues)
- Screenshots or examples if applicable
```bash
uv run pre-commit install \
--hook-type pre-commit \
--hook-type commit-msg \
--hook-type pre-push
```
## Coding Standards
At **commit** time: ruff lint and format, biome for TypeScript, basedpyright, bandit,
markdownlint, and file hygiene. At **push** time: pytest, the alembic migration tests, and
the SDK builds.
### Python Code Style
That split matters — **a clean commit is not a clean push.** The test suite only runs at
`pre-push`, so the first time you see test failures may be well after you thought you were
done.
- Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines
- Use [Black](https://black.readthedocs.io/) for code formatting (we may add this to CI in the future)
- Use type hints where possible
- Write docstrings for functions and classes using Google style docstrings
Run them by hand at any time:
### Code Organization
```bash
uv run pre-commit run --all-files
uv run pre-commit run ruff --all-files
```
- Keep functions focused and single-purpose
- Use meaningful variable and function names
- Add comments for complex logic
- Follow existing patterns in the codebase
Or the individual tools:
### Testing
```bash
uv run ruff check src/
uv run ruff format src/
uv run basedpyright
```
- Write unit tests for new functionality
- Ensure existing tests pass before submitting
- Use descriptive test names that explain what is being tested
- Mock external dependencies appropriately
### Tests
Write tests for new functionality, in the directory under `tests/` that mirrors the code you
changed. Which layer you need depends on what you touched:
| What you changed | What to run |
| --- | --- |
| Anything in `src/` | Unit tests in the matching `tests/` tree — `uv run pytest tests/...` |
| Deriver, dialectic, dreamer, or the LLM path | Unit tests, and consider `tests/live_llm/` (gated behind `--live-llm`) |
| Queue behavior, config hierarchy, multi-turn flows, SDK contracts | `uv run python -m tests.unified.run` |
| A `/v3` endpoint or deriver queue behavior | Actually run the stack and exercise it — not just pytest |
| A migration | `uv run python scripts/run_alembic_tests.py`; every revision needs a test file |
The TypeScript SDK tests need a running server with a database and Redis, which pytest
orchestrates. Run them with `uv run pytest tests/ -k typescript` from the repo root —
`bun test` on its own will fail. To type-check the SDK alone:
`cd sdks/typescript && bun run tsc --noEmit`.
### Documentation
- Update relevant documentation for new features
- Include examples in docstrings where helpful
- Keep README and other docs up to date with changes
Update docs in the same PR when you change a public surface: `/v3` endpoints, SDK exports,
or anything in `config.toml` / settings. Docs live in `docs/v3/`, and new pages need an entry
in `docs/docs.json` or they will not appear in the nav.
## Review Process
## Opening the pull request
1. **Automated checks** - Your PR will run through automated checks including tests and linting
2. **Project maintainer review** - A project maintainer will review your code for:
- Code quality and adherence to standards
- Functionality and correctness
- Test coverage
- Documentation completeness
3. **Discussion and iteration** - You may be asked to make changes or clarifications
4. **Approval and merge** - Once approved, your PR will be merged into `main`
### Leave "Allow edits by maintainers" checked
## Types of Contributions
This is the single most useful thing you can do to get your PR merged quickly.
We welcome various types of contributions:
Most contributor PRs arrive nearly right, needing a rename, a missing test, or a lint fix.
If we can push that commit ourselves, it merges the same day. If we cannot, it becomes a
review comment, and then we wait — sometimes for weeks — for a round trip on a two-line
change.
- **Bug fixes** - Help us squash bugs and improve stability
- **New features** - Add functionality that benefits the community
- **Documentation** - Improve or expand our documentation
- **Tests** - Increase test coverage and reliability
- **Performance improvements** - Help make Honcho faster and more efficient
- **Examples and tutorials** - Help other developers use Honcho
GitHub checks the box by default when you fork. Leave it checked.
## Issue Reporting
One caveat worth knowing: **the option does not exist on forks owned by an organization.**
If you have the choice, fork from your personal account.
When reporting bugs or requesting features:
### Fill out the template
1. Check if the issue already exists
2. Use the appropriate issue template
3. Provide clear reproduction steps for bugs
4. Include relevant environment information
5. Be specific about expected vs actual behavior
`.github/pull_request_template.md` asks for a description, proofs, and the issue checkbox.
## Questions and Support
"Proofs" means evidence the change works: the command you ran and its result, a log snippet,
a screenshot, the failing case before and after. This is the section that most determines
how fast your PR gets reviewed. Do not add sections to the template.
- **General questions** - Join our [Discord](http://discord.gg/honcho)
- **Bug reports** - Use GitHub issues
- **Feature requests** - Use GitHub issues with the feature request template
- **Security issues** - Please email us privately rather than opening a public issue
Link the issue so the gate can see it: `Fixes #123` in the description, or the
**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so
either route works — but a bare `#123` mention is only a reference and does not count.
### Review
1. Automated checks run — tests, linting, static analysis, and the issue gate.
2. A maintainer reviews for correctness, test coverage, and fit with the surrounding code.
`.github/CODEOWNERS` routes the request to whoever owns the area you touched.
3. You may be asked for changes. Or we may just push them, if you left edits enabled.
4. Once approved, we merge to `main`.
If a PR goes quiet, nudge us in [Discord](https://discord.gg/honcho).
## Reporting bugs and requesting features
Use the [issue templates](https://github.com/plastic-labs/honcho/issues/new/choose). There is
one per kind of report, and picking the right one is most of what gets an issue triaged
quickly:
- **Bug report** — something is broken or behaves incorrectly
- **Memory / recall quality** — the deriver or dialectic returns poor, wrong, or missing context
- **Feature request** — a new capability or API surface
- **Integration request** — plugins, framework integrations, app-store listings
- **Documentation issue** — anything wrong or missing in the docs
- **General questions** — not an issue at all; ask in [Discord](https://discord.gg/honcho)
Before opening one, search existing issues, including closed ones.
A good bug report has the Honcho version or commit, whether you are self-hosted or on
`api.honcho.dev`, the steps to reproduce, and what you expected instead. If it involves the
deriver, logs from the worker process are usually the thing we ask for first.
**Redact before you post.** Issues are public, and Honcho stores conversational data — strip
API keys, JWTs, and production user content out of any log or payload you attach.
## Security
Do not open a public issue for a suspected vulnerability. Report it privately through
[GitHub Private Vulnerability Reporting](https://github.com/plastic-labs/honcho/security/advisories/new),
which is the preferred channel, or by email. See [SECURITY.md](https://github.com/plastic-labs/honcho/blob/main/SECURITY.md) for what
to include, and note that Honcho does not operate a bug bounty.
## License
By contributing to Honcho, you agree that your contributions will be licensed under the same [AGPL-3.0 License](./license) that covers the project.
By contributing to Honcho, you agree that your contributions will be licensed under the same
[AGPL-3.0 License](./license) that covers the project.
Thank you for helping make Honcho better! 🫡

View File

@ -7,6 +7,8 @@ icon: 'computer'
This guide helps you set up a local environment to run Honcho for development, testing, or self-hosting.
**Just want a running instance?** `uv tool install honcho-cli` only installs the `honcho` command. Then run [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) (Docker + an LLM provider key) — that pulls a published image and starts API, deriver, Postgres, and Redis. The rest of this page is for building from source, contributing, or deploying without the CLI.
## Overview
By the end of this guide, you'll have:
@ -22,7 +24,7 @@ Before you begin, ensure you have the following installed:
### Required Software
- **uv** - Python package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh` or `brew install uv`
- **Git** - [Download from git-scm.com](https://git-scm.com/downloads)
- **Docker** (required for Docker setup, not needed for manual setup) - [Download from docker.com](https://www.docker.com/products/docker-desktop/)
- **Docker** - required for the CLI local stack and the compose-from-source path; not needed for a fully manual setup. [Download from docker.com](https://www.docker.com/products/docker-desktop/)
### Database Options
You'll need a PostgreSQL database with the pgvector extension. Choose one:
@ -59,13 +61,22 @@ DERIVER_MODEL_CONFIG__OVERRIDES__BASE_URL=https://openrouter.ai/api/v1
For recommended model tiers per feature, using multiple providers, or direct vendor API keys, see the [Configuration Guide](./configuration#llm-configuration).
</Info>
<Info>
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers, interactive provider setup, and Hermes Agent integration.
</Info>
## Personal local stack (CLI)
## Docker Setup (Recommended)
Recommended if you want Honcho running locally without cloning this repo or building an image. Install the CLI, then run the setup wizard:
Docker Compose handles the database, Redis, and Honcho server. The compose file **builds the image from source** (there is no pre-built image on Docker Hub). This requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails.
```bash
uv tool install honcho-cli
honcho start --setup basic # prompts for LLM provider + key, then starts Docker
```
`honcho start` pulls `ghcr.io/plastic-labs/honcho:latest`, pins that digest, and starts API + deriver + Postgres + Redis. Stack files live under `~/.honcho/profiles/local/`. It does **not** rewrite `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). Talk to the stack with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the CLI default.
See the [CLI reference](/v3/documentation/reference/cli#local-stack) for `--setup`, profiles, `--image`, ports, `honcho status` / `stop`, and pointing the CLI at local.
## From source (Docker Compose)
Docker Compose in this repo handles the database, Redis, and Honcho server. The compose file **builds the image from source** so you can develop against local code. A pre-built image is published at `ghcr.io/plastic-labs/honcho:latest` (what `honcho start` uses); it is not on Docker Hub. Building from source requires Docker with BuildKit enabled — see [Troubleshooting](./troubleshooting#docker-build-fails-with-permission-errors) if the build fails.
The compose file is production-oriented by default (ports bound to `127.0.0.1`, restart policies, caching enabled). For development, uncomment the source mounts and monitoring services inside the file.
@ -321,6 +332,7 @@ const client = new Honcho({
### Next Steps
- **Configure Honcho**: Visit the [Configuration Guide](./configuration) for model tiers, provider options, and tuning
- **Use the CLI**: install with `uv tool install honcho-cli`, then [`honcho start --setup`](/v3/documentation/reference/cli#local-stack) for a local stack; inspect with `honcho workspace inspect` / `honcho doctor`
- **Explore the API**: Check out the [API Reference](../api-reference/introduction)
- **Try the SDKs**: See our [guides](../guides) for examples
- **Join the community**: [Discord](https://discord.gg/honcho)
@ -334,11 +346,12 @@ Running into issues? See the [Troubleshooting Guide](./troubleshooting) for deta
- Deriver not processing messages
- Database connection and migration issues
- Docker and Redis problems
- CLI local stack (`honcho start`) — missing LLM key, health timeout, still talking to api.honcho.dev
**Quick checks:**
- Verify the server is running: `curl http://localhost:8000/health`
- Check logs: `docker compose logs api` (Docker) or check terminal output (manual setup)
- Ensure migrations ran: `uv run alembic upgrade head`
- Check logs: `docker compose logs api` (from-source Docker), `docker compose -p honcho-local logs` (`honcho start`), or terminal output (manual setup)
- Ensure migrations ran: `uv run alembic upgrade head` (from-source only; `honcho start` runs them in the image entrypoint)
## Production Considerations

View File

@ -316,11 +316,37 @@ docker compose build --no-cache
docker compose up -d
```
## CLI local stack (`honcho start`)
The CLI local stack lives under `~/.honcho/profiles/` (default profile `local`) and uses the published image `ghcr.io/plastic-labs/honcho:latest`. Logs: `docker compose -p honcho-local logs`. Full flags: [CLI reference](/v3/documentation/reference/cli#local-stack).
### `MISSING_LLM_KEY`
**Cause:** No provider key in the environment or the profile `.env`.
**Fix:** Export `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY`, or run `honcho start --setup basic` in a TTY (not with `--json`).
### Timed out waiting for `/health`
**Cause:** The API container started but did not become ready within `--timeout` (default 180s).
**Fix:** Check `docker compose -p honcho-local logs api` and `... logs deriver`. Increase `--timeout`. Confirm Docker is running. A first-time GHCR pull happens *before* this wait (during image pin) — if that step hung, look at Docker pull logs instead.
### CLI still talks to `api.honcho.dev`
**Cause:** `honcho start` does not rewrite `environmentUrl` in `~/.honcho/config.json`.
**Fix:** Prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000`, or run `honcho init --base-url http://127.0.0.1:8000` to persist local as the default. `honcho status` prints the one-shot hint.
### Port already in use
The CLI remaps 8000/5432/6379 automatically unless you pinned them with `--api-port` / `--db-port` / `--redis-port`. Pass those flags if you need a specific host port.
## Getting Help
If your issue isn't covered here:
- **Check the logs** — most issues are diagnosed from server or deriver logs
- **Check the logs** — most issues are diagnosed from server or deriver logs (`docker compose logs` for from-source compose; `docker compose -p honcho-local logs` for `honcho start`)
- **GitHub Issues** — [Report bugs](https://github.com/plastic-labs/honcho/issues)
- **Discord** — [Join our community](https://discord.gg/plasticlabs)
- **Configuration** — See the [Configuration Guide](./configuration) for all available settings

View File

@ -94,6 +94,9 @@ Welcome to Honcho. We're excited to have you at the frontier of AI with us 🫡.
<Card title="Quickstart" icon="rocket" href="/v3/documentation/introduction/quickstart">
Build your first stateful agent in minutes
</Card>
<Card title="CLI" icon="terminal" href="/v3/documentation/reference/cli">
Inspect a deployment, or `honcho start --setup` a local stack
</Card>
<Card title="Architecture" icon="sitemap" href="/v3/documentation/core-concepts/architecture">
Deep dive into how Honcho's primitives fit together
</Card>

View File

@ -11,9 +11,11 @@ Let's get started with Honcho. In this quickstart, you will:
- Query the reasoning Honcho produces to get synthesized insights about the user
<Note>
Running the code below requires an API key. Create and account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS".
Running the code below requires an API key. Create an account and get your API key at [app.honcho.dev](https://app.honcho.dev) under "API KEYS".
Every new tenant gets \$100.00 in free credits on sign up. The code below costs ~\$0.04 to run, so don't worry--still plenty of free credits for iterating.
To run against a local stack instead, install the CLI (`uv tool install honcho-cli`) and then run `honcho start --setup` (Docker + an LLM provider key). See the [CLI reference](/v3/documentation/reference/cli#local-stack).
</Note>
#### 1. Install the SDK

View File

@ -68,16 +68,19 @@ claude mcp add honcho \
## CLI
Inspect and debug a running Honcho deployment from your terminal. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions).
Inspect and debug a running Honcho deployment from your terminal, or run a personal local stack. The honcho CLI wraps the Python SDK with agent-friendly defaults — JSON output, structured errors, and commands for every primitive (workspaces, peers, sessions, messages, conclusions).
**Get started:**
```bash
uv tool install honcho-cli
honcho init # configure apiKey + environmentUrl
honcho doctor # verify connectivity
honcho init # Honcho API key / browser login (talk *to* a server)
honcho start --setup basic # local stack: LLM provider key + Docker
honcho doctor # verify connectivity
```
`honcho start --setup` pulls the published GHCR image — no clone required. It does not rewrite `environmentUrl` in the shared config file; prefix commands with `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local.
The CLI also ships an agent skill. Install it with `npx skills add plastic-labs/honcho` and pick `honcho-cli` from the list.
See the [full CLI reference](/v3/documentation/reference/cli) for all commands, flags, and environment variables.
@ -147,7 +150,7 @@ Invoke with `/honcho-integration` in your coding agent.
#### honcho-cli
**For inspection & debugging.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality.
**For inspection & debugging, and for running a local stack.** Teaches your coding agent the right commands and flags for the [honcho CLI](#cli) — peer memory, session context, queue status, dialectic quality, `honcho start` / `status` / `stop`.
Invoke implicitly when you ask your agent to inspect a Honcho deployment.
@ -170,7 +173,7 @@ I want to start building with Honcho - an open source memory library for buildin
- Core repo: https://github.com/plastic-labs/honcho
- Python SDK: https://github.com/plastic-labs/honcho-python
- TypeScript SDK: https://github.com/plastic-labs/honcho-node
- CLI (inspect & debug a deployment): https://github.com/plastic-labs/honcho/tree/main/honcho-cli
- CLI (inspect, debug, or `honcho start` a local stack): https://github.com/plastic-labs/honcho/tree/main/honcho-cli
- Discord bot starter: https://github.com/plastic-labs/discord-python-starter
- Telegram bot example: https://github.com/plastic-labs/telegram-python-starter

View File

@ -18,14 +18,45 @@ uvx honcho-cli
```
</CodeGroup>
This only installs the `honcho` command. It does not start a server. Use `honcho start --setup` (Docker + an LLM provider key) when you want a local stack.
## Quick Start
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho doctor # verify your config + connectivity
honcho # show banner + command list
honcho init # Honcho API key or browser login + server URL (talk *to* Honcho)
honcho start --setup basic # local stack: LLM provider key + Docker (not set by init)
honcho doctor # verify your config + connectivity
honcho # show banner + command list
```
`honcho init` authenticates the CLI against a Honcho server. It does **not** configure the LLM key a local stack needs — that is `honcho start --setup`.
## Local stack
`honcho start --setup basic` is the fastest way to run Honcho on your machine. It does **not** require cloning the Honcho repo. The wizard prompts for an LLM provider and API key, writes them into the profile `.env`, pulls `ghcr.io/plastic-labs/honcho:latest`, **pins that digest**, and starts API + deriver + Postgres + Redis via Docker.
Default profile is `local` (`--profile` / `HONCHO_PROFILE`). First start copies the image `config.toml.example` into the profile directory; later starts leave that file alone so your edits persist — including when you re-pin the image. Delete `config.toml` yourself if you want a fresh copy from a new image. Pass `--image` to pin a different tag or digest. Ports bind to `127.0.0.1`; if 8000/5432/6379 are taken, the CLI remaps them (or pass `--api-port` / `--db-port` / `--redis-port`). Auth is off (`AUTH_USE_AUTH=false`).
Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (environment variables win over `config.toml`). This is TTY-only. `basic` covers provider and chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and deriver flush. Re-running `--setup` while the stack is up recreates the API and deriver containers.
This does **not** change `environmentUrl` in the shared config file. To talk to the local stack:
```bash
HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list
honcho init --base-url http://127.0.0.1:8000 # persist local as the CLI default
```
```bash
honcho start --setup basic
honcho start --setup advanced
LLM_OPENAI_API_KEY=sk-... honcho start # skip the wizard if the key is already in the env
honcho status
honcho stop # keep data
honcho stop --wipe # also delete volumes
```
To **develop the server** (live reload, from-source image), see [Local Environment Setup](/v3/contributing/self-hosting).
## Configuration
The CLI resolves config in this order: **flag → env var → config file → default**.
@ -38,12 +69,17 @@ The CLI resolves config in this order: **flag → env var → config file → de
| Peer | — | `HONCHO_PEER_ID` | `-p` / `--peer` | No |
| Session | — | `HONCHO_SESSION_ID` | `-s` / `--session` | No |
| JSON output | — | `HONCHO_JSON` | `--json` | No |
| Update nag | — | `HONCHO_NO_UPDATE_CHECK` | — | No |
| Local stack | — | `HONCHO_PROFILE` | `--profile` | No |
### Persisted config
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only
The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns
`apiKey` and `environmentUrl` at the top level — everything else (`hosts`,
`sessions`, etc.) is written by other tools and left untouched on save.
On managed servers that advertise the device grant in OAuth metadata,
`honcho init` can log you in via the browser; tokens auto-refresh
and are stored under `oauth` without deleting a shared `apiKey`.
```json
{
@ -89,6 +125,8 @@ Every command adapts its output to the context:
- **Piped or redirected** — JSON automatically (detected via `isatty`).
- **`--json` flag / `HONCHO_JSON=1`** — force JSON regardless of terminal.
Interactive sessions may print a one-line upgrade hint on stderr at most once a day when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). JSON/piped output skips it; set `HONCHO_NO_UPDATE_CHECK=1` to disable it.
Collection commands emit JSON arrays; single-resource commands emit JSON objects. Errors are always structured:
```json

View File

@ -67,10 +67,6 @@ Or manually create/edit the config file (checked in order: `$HERMES_HOME/honcho.
For the full list of config fields (`recallMode`, `writeFrequency`, `sessionStrategy`, `dialecticReasoningLevel`, etc.), see the [Hermes memory provider docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory-providers#honcho).
<Info>
**Community quick-start**: [elkimek/honcho-self-hosted](https://github.com/elkimek/honcho-self-hosted) provides a one-command installer with pre-configured model tiers and Hermes Agent integration.
</Info>
## Verifying the integration
### 1. Check status

View File

@ -9,7 +9,7 @@
"url": "https://honcho.dev/",
"email": "hello@plasticlabs.ai"
},
"version": "3.0.12"
"version": "3.1.0"
},
"servers": [
{

View File

@ -7,13 +7,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [0.1.4] - 2026-08-26
### Added
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session
- A TTY notice when a newer `honcho-cli` is on PyPI (`uv tool upgrade honcho-cli`). Skipped in JSON mode; disable with `HONCHO_NO_UPDATE_CHECK`
### Fixed
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window
- `--setup` for openai-compatible writes `EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL` into the profile `.env` alongside `LLM_OPENAI_BASE_URL` (#1068)
- `--setup` API key prompts echo `*` per character so a paste is visibly received instead of a blank getpass field
## [0.1.3] - 2026-08-25
### Added
- `honcho start`, `honcho stop`, and `honcho status` — run a personal Honcho stack in Docker (API, deriver, Postgres, Redis). Profiles live under `~/.honcho/profiles/`. First start pins `ghcr.io/plastic-labs/honcho:latest` by digest and copies the image `config.toml`. Optional `--setup basic` / `--setup advanced` wizard writes LLM overrides to `.env` (#1029)
- `honcho session view` — session transcript table (`--last N`, `--page N --size M`, `--all`, `--reverse`, `--ids`, peer filter via `-p`). Content is shown verbatim, timestamps are normalized to UTC, and the command is read-only: unlike the other session commands it never get-or-creates the session (#1006)
### Fixed
- `honcho message list --last N` no longer stops at the first page of 50 — it walks pages to fill the requested window (#1006)
## [0.1.2] - 2026-07-20

View File

@ -22,22 +22,50 @@ uv tool install honcho-cli
## Quick Start
```bash
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho doctor # verify your config + connectivity
honcho # show banner + command list
honcho init # confirm/set apiKey + Honcho URL in ~/.honcho/config.json
honcho start --setup basic # local stack: LLM provider key + Docker
honcho doctor # verify config + connectivity
honcho # show banner + command list
```
`honcho init` reads `apiKey` and `environmentUrl` from the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share). If both are present, it confirms them with you; if either is missing (or you decline), it prompts for the missing value(s) and writes them back. Host-specific entries under `hosts` are left untouched.
`honcho init` writes `apiKey` and `environmentUrl` to the top-level of `~/.honcho/config.json` (the same file other Honcho tools — plugins, host integrations — share) so the CLI can call a Honcho server. If both are present, it confirms them with you; if either is missing (or you decline), it prompts and writes them back. Host-specific entries under `hosts` are left untouched. It does **not** set the LLM provider key the local deriver needs — that is `honcho start --setup` (or `LLM_*_API_KEY` in the environment).
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s` flags or `HONCHO_*` env vars — not persisted as CLI defaults.
### Local stack
`honcho start --setup basic` runs a personal Honcho server on your machine (API, deriver, Postgres, Redis) via Docker. The wizard writes the LLM provider key into the profile `.env``honcho init` cannot do this; its `apiKey` is for calling a Honcho server, not for deriver/dialectic inference. You can also pass `LLM_OPENAI_API_KEY`, `LLM_ANTHROPIC_API_KEY`, or `LLM_GEMINI_API_KEY` in the environment and skip `--setup`. Stack files live under `~/.honcho/profiles/local/` and are not committed to a project.
On first start, the CLI pulls `ghcr.io/plastic-labs/honcho:latest` and **pins that digest** in `profile.json`, then copies the image's `config.toml.example` to `config.toml` in the same directory. `honcho start` never overwrites `config.toml` after that — including when you re-pin the image. Delete the file yourself if you want a fresh copy from a new image.
Pass `--setup basic` or `--setup advanced` for an interactive wizard that writes curated LLM/feature overrides into the profile `.env` (env wins over `config.toml`). TTY only; re-runnable. `basic` asks provider + chat model; `advanced` also covers embeddings, deriver/dialectic models, dreams, and snappy deriver flush. Everything else stays in `config.toml`.
`honcho start` does **not** change `environmentUrl` in `~/.honcho/config.json` (that file is shared with plugins). To talk to the local stack for one command:
```bash
HONCHO_BASE_URL=http://127.0.0.1:8000 honcho workspace list
```
To make local the default, run `honcho init --base-url http://127.0.0.1:8000`.
```bash
honcho start --setup basic
honcho start --setup advanced
honcho status
honcho stop # keep data
honcho stop --wipe # also delete volumes
```
## Commands
### Onboarding
| Command | Description |
|---------|-------------|
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json` |
| `honcho init` | Confirm/set `apiKey` + `environmentUrl` in `~/.honcho/config.json`. |
| `honcho start` | Start a local Honcho stack (API, deriver, Postgres, Redis). Requires Docker and a cloud LLM key. `--setup basic` / `--setup advanced` runs an interactive config wizard (TTY only). Does not change `environmentUrl`. |
| `honcho stop` | Stop the local stack. `--wipe` also deletes volumes. |
| `honcho status` | Show every local stack (or `--profile` for one). |
| `honcho doctor` | Health check: config, connectivity, workspace, peer, queue |
### Workspaces
@ -157,6 +185,9 @@ Precedence (highest first): **flag → env var → config file → default**.
| `HONCHO_PEER_ID` | `-p` / `--peer` | Peer scope |
| `HONCHO_SESSION_ID` | `-s` / `--session` | Session scope |
| `HONCHO_JSON` | `--json` | Force JSON output (`1` / `true`) |
| `HONCHO_NO_UPDATE_CHECK` | — | Disable the once-a-day upgrade notice (`1` / `true`) |
| `HONCHO_PROFILE` | `--profile` (start/stop/status) | Local stack profile (default: `local`) |
| `LLM_OPENAI_API_KEY` | — | Provider key for `honcho start` (also `LLM_ANTHROPIC_API_KEY`, `LLM_GEMINI_API_KEY`) |
```bash
# Per-command flags

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-cli"
version = "0.1.2"
version = "0.1.4"
description = "A terminal for Honcho — memory that reasons."
readme = "README.md"
requires-python = ">=3.11"
@ -38,6 +38,10 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/honcho_cli"]
[tool.hatch.build.targets.wheel.force-include]
"src/honcho_cli/local/templates/docker-compose.yml" = "honcho_cli/local/templates/docker-compose.yml"
"src/honcho_cli/local/templates/init.sql" = "honcho_cli/local/templates/init.sql"
[tool.pytest.ini_options]
testpaths = ["tests"]

View File

@ -1,3 +1,3 @@
"""Honcho CLI — a terminal for Honcho."""
__version__ = "0.1.2"
__version__ = "0.1.4"

View File

@ -24,6 +24,7 @@ from typer.core import TyperGroup
from honcho_cli import __version__
from honcho_cli.branding import BANNER, BRAND
from honcho_cli.output import use_json
from honcho_cli.update_check import maybe_print_update_nag
# Theme Typer's rich help renderer. Module-level side effect limited to
@ -59,15 +60,21 @@ def _welcome_panel(title: str, rows: list[tuple[str, str]]) -> Panel:
def print_welcome(console: Console) -> None:
"""Render the curated 3-panel welcome (banner + getting started / memory / commands)."""
"""Render the curated welcome (banner + getting started / local stack / commands / memory)."""
if use_json():
return
console.print(f"[bold {BRAND}]{BANNER}[/bold {BRAND}]")
console.print(f" [dim]v{__version__}[/dim]\n", highlight=False)
start_rows = [
("honcho init", "configure API key and server URL"),
("honcho doctor", "verify connection and workspace health"),
("honcho init", "configure API key and server URL"),
("honcho start [--setup basic]", "run a local Honcho stack (Docker)"),
("honcho doctor", "verify connection and workspace health"),
]
stack_rows = [
("honcho start / status / stop", "lifecycle for the local Docker stack"),
("honcho start --setup basic", "interactive LLM + feature wizard"),
("HONCHO_BASE_URL=http://127.0.0.1:8000", "prefix any command — CLI stays on api.honcho.dev until you set this"),
]
cmd_rows = [
("[dim]pattern[/dim]", r"[dim]honcho <command> \[args] \[-w workspace] \[-p peer] \[-s session][/dim]"),
@ -84,14 +91,15 @@ def print_welcome(console: Console) -> None:
("config", "inspect current configuration"),
]
memory_rows = [
("honcho peer chat \"...\" -p <peer> -w <workspace>","query the Dialectic about a peer"),
("honcho peer inspect -p <peer> -w <workspace>","dashboard: peer card + recent conclusions + configuration"),
("honcho peer chat \"...\" -p <peer> -w <workspace>", "query the Dialectic about a peer"),
("honcho peer inspect -p <peer> -w <workspace>", "dashboard: peer card + recent conclusions + configuration"),
("honcho peer representation -p <peer> -w <workspace>", "global peer representation"),
("honcho peer representation -p <peer> -w <workspace> -s <session>", "session-scoped peer representation"),
("honcho peer card -p <peer> -w <workspace>", "synthesized identity: traits, preferences, instructions"),
("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
("honcho conclusion list -p <peer> -w <workspace>", "browse peer conclusions"),
("honcho session view / context -s <session>", "transcript, or what an agent would see"),
("honcho workspace queue-status", "is the deriver processing?"),
]
option_rows = [
("-w / --workspace", "scope to a workspace"),
("-p / --peer", "scope to a peer"),
@ -101,10 +109,12 @@ def print_welcome(console: Console) -> None:
]
console.print(_welcome_panel("getting started", start_rows))
console.print(_welcome_panel("local stack", stack_rows))
console.print(_welcome_panel("commands", cmd_rows))
console.print(_welcome_panel("memory", memory_rows))
console.print(_welcome_panel("options", option_rows))
console.print()
maybe_print_update_nag()
class HonchoTyperGroup(TyperGroup):

View File

@ -0,0 +1,400 @@
"""Local stack lifecycle: ``honcho start``, ``honcho stop``, ``honcho status``.
Does not mutate ``~/.honcho/config.json``. The CLI stays pointed at whatever
``honcho init`` configured (typically api.honcho.dev). Print the local URL
and a one-shot ``HONCHO_BASE_URL=...`` hint instead.
"""
from __future__ import annotations
import typer
from rich.console import Console
from honcho_cli.branding import BRAND, ICON_FAIL, ICON_OK
from honcho_cli.local import (
DEFAULT_HEALTH_TIMEOUT,
DEFAULT_IMAGE,
DEFAULT_PROFILE,
STACK_SERVICES,
)
from honcho_cli.local.docker import (
DockerError,
allocate_host_ports,
compose_down,
compose_ps,
compose_up,
pin_image,
seed_config_toml,
services_running,
)
from honcho_cli.local.env import has_provider_key, render_stack, settings_from_environ
from honcho_cli.local.health import stack_healthy, wait_for_health
from honcho_cli.local.profile import (
LocalProfile,
list_profile_names,
load_profile,
resolve_profile_name,
save_profile,
)
from honcho_cli.local.setup import (
SETUP_MODES,
answers_drop_keys,
answers_to_env,
run_setup,
)
from honcho_cli.output import (
fail,
ok,
print_error,
print_json,
print_result,
set_json_mode,
step,
use_json,
)
_console = Console(stderr=True)
_MISSING_LLM_KEY = (
"Set LLM_OPENAI_API_KEY, LLM_ANTHROPIC_API_KEY, or LLM_GEMINI_API_KEY, "
"or run honcho start --setup basic."
)
def _die(code: str, message: str, details: dict | None = None) -> None:
print_error(code, message, details)
raise typer.Exit(1)
def _validate_setup(setup: str | None) -> str | None:
if setup is None:
return None
mode = setup.strip().lower()
if mode not in SETUP_MODES:
_die(
"INVALID_SETUP",
f"Unknown setup mode {setup!r}. Use --setup basic or --setup advanced.",
{"setup": setup},
)
if use_json():
_die(
"SETUP_REQUIRES_TTY",
"honcho start --setup is interactive. Run it in a terminal without --json.",
{"setup": mode},
)
return mode
def _payload(
profile: LocalProfile, status: str, services: dict[str, str] | None = None
) -> dict:
return {
"profile": profile.name,
"status": status,
"image": profile.image,
"endpoints": profile.endpoints(),
"services": services or {},
"hint": f"HONCHO_BASE_URL={profile.base_url} honcho workspace list",
}
def _print_stack(payload: dict) -> None:
if use_json():
print_json(payload)
return
endpoints = payload["endpoints"]
_console.print()
table_data = {
"API": endpoints["api"],
"Docs": endpoints["docs"],
"Postgres": endpoints["postgres"],
"Redis": endpoints["redis"],
}
print_result(table_data)
_console.print()
_console.print(
" [dim]CLI still points at your configured server (typically api.honcho.dev).[/dim]"
)
_console.print(f" [dim]To talk to this stack:[/dim] {payload['hint']}")
_console.print()
def _print_running(profile: LocalProfile) -> None:
_print_stack(_payload(profile, "running", services_running(compose_ps(profile))))
def _seed_config(profile: LocalProfile) -> None:
if seed_config_toml(profile):
ok("config.toml")
def _inspect(profile: LocalProfile) -> tuple[dict[str, str], bool]:
"""Compose service states and whether the API is healthy."""
return services_running(compose_ps(profile)), stack_healthy(profile)
def start(
profile_name: str = typer.Option(
DEFAULT_PROFILE,
"--profile",
envvar="HONCHO_PROFILE",
help="Local stack profile name",
),
api_port: int | None = typer.Option(
None, "--api-port", min=1, max=65535, help="Host port for the API"
),
db_port: int | None = typer.Option(
None, "--db-port", min=1, max=65535, help="Host port for Postgres"
),
redis_port: int | None = typer.Option(
None, "--redis-port", min=1, max=65535, help="Host port for Redis"
),
setup: str | None = typer.Option(
None,
"--setup",
help="Interactive config wizard: basic (provider/model) or advanced "
"(embeddings, deriver, dialectic, dreams, flush)",
),
image: str | None = typer.Option(
None,
"--image",
help=f"Honcho image to pull and pin by digest (default: {DEFAULT_IMAGE})",
),
timeout: int = typer.Option(
DEFAULT_HEALTH_TIMEOUT,
"--timeout",
min=1,
help="Seconds to wait for /health after compose up",
),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Start a local Honcho stack (API, deriver, Postgres, Redis).
Requires Docker. Uses cloud LLM providers. Does not change the CLI's
configured server URL pass HONCHO_BASE_URL to talk to this stack.
``--setup basic`` or ``--setup advanced`` runs an interactive config wizard.
"""
if json_output:
set_json_mode(True)
setup = _validate_setup(setup)
name = resolve_profile_name(profile_name)
profile = load_profile(name).overlay(
api_port=api_port,
db_port=db_port,
redis_port=redis_port,
image=image,
)
pinned_ports = frozenset(
name
for name, value in (
("api", api_port),
("database", db_port),
("redis", redis_port),
)
if value is not None
)
if not use_json():
_console.print(f"\n[bold {BRAND}]Honcho Start[/bold {BRAND}]\n")
try:
already_running = stack_healthy(profile)
if already_running and not setup:
ok(f"Already running ({profile.base_url})")
_print_running(profile)
return
if not already_running:
profile, remapped = allocate_host_ports(profile, pinned=pinned_ports)
for service, (old, new) in remapped.items():
step(f"Port {old} in use; {service} on {new}")
step(f"Pinning {profile.image}")
pinned_image = pin_image(profile.image)
profile = profile.overlay(image=pinned_image)
ok(pinned_image)
extra = settings_from_environ()
drop: tuple[str, ...] = ()
_seed_config(profile)
if setup:
answers = run_setup(
setup,
profile.env_file(),
config_path=profile.config_file(),
)
extra.update(answers_to_env(answers))
drop = answers_drop_keys(answers)
ok(f"Wrote overrides to {profile.env_file()}")
_console.print(
f" [dim]Other settings live in {profile.config_file()}[/dim]"
)
elif not has_provider_key(profile, extra):
_die("MISSING_LLM_KEY", _MISSING_LLM_KEY)
step(f"Writing stack config to {profile.dir()}")
save_profile(profile)
render_stack(profile, extra=extra, drop=drop)
ok(f"Profile '{profile.name}'")
step("Starting containers" if not already_running else "Recreating api + deriver")
compose_up(
profile,
recreate=("api", "deriver") if already_running else (),
)
step(f"Waiting for API at {profile.base_url}/health")
if not wait_for_health(profile, timeout=float(timeout)):
fail("Timed out waiting for /health")
_die(
"HEALTH_TIMEOUT",
f"Stack started but {profile.base_url}/health did not become ready within {timeout}s. "
f"Check `docker compose -p {profile.project_name} logs`.",
{
"base_url": profile.base_url,
"timeout": timeout,
"project": profile.project_name,
},
)
ok("Honcho is running")
_print_running(profile)
except DockerError as e:
e.exit()
def stop(
profile_name: str = typer.Option(
DEFAULT_PROFILE,
"--profile",
envvar="HONCHO_PROFILE",
help="Local stack profile name",
),
wipe: bool = typer.Option(
False, "--wipe", help="Also delete volumes (Postgres data)"
),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Stop the local stack started by `honcho start`. Keeps data unless --wipe."""
if json_output:
set_json_mode(True)
name = resolve_profile_name(profile_name)
profile = load_profile(name)
try:
if not profile.compose_file().exists():
payload = _payload(profile, "stopped")
if use_json():
print_json(payload)
else:
_console.print(
f" [dim]No local stack for profile '{profile.name}'.[/dim]"
)
return
running = bool(compose_ps(profile))
if not running and not wipe:
if use_json():
print_json(_payload(profile, "stopped"))
else:
_console.print(
f" [dim]Profile '{profile.name}' is already stopped.[/dim]"
)
return
compose_down(profile, wipe=wipe)
except DockerError as e:
e.exit()
state = "wiped" if wipe else "stopped"
ok(f"Stopped profile '{profile.name}'" + (" (volumes removed)" if wipe else ""))
if use_json():
print_json(_payload(profile, state))
def _status_one(profile: LocalProfile) -> bool:
"""Print one profile's status. Return True when the API is healthy."""
try:
services, running = _inspect(profile)
except DockerError as e:
e.exit()
data = _payload(profile, "running" if running else "stopped", services)
if not use_json():
icon = ICON_OK if running else ICON_FAIL
_console.print(f"\n {icon} profile '{profile.name}' is {data['status']}\n")
if services:
for svc in STACK_SERVICES:
detail = services.get(svc, "missing")
_console.print(f" {svc:<10} [dim]{detail}[/dim]")
_print_stack(data)
return running
def status(
profile_name: str | None = typer.Option(
None,
"--profile",
envvar="HONCHO_PROFILE",
help="Limit to this profile. Omit to show every local stack.",
),
json_output: bool = typer.Option(False, "--json", help="Force JSON output"),
) -> None:
"""Show local stack endpoints and container health.
With no ``--profile``, lists every stack under ``~/.honcho/profiles/``.
"""
if json_output:
set_json_mode(True)
if profile_name:
name = resolve_profile_name(profile_name)
profile = load_profile(name)
if not profile.compose_file().exists():
_die(
"STACK_NOT_FOUND",
f"No local stack for profile '{profile.name}'. Run `honcho start` first.",
{"profile": profile.name},
)
if not _status_one(profile):
raise typer.Exit(1)
return
names = list_profile_names()
if not names:
_die(
"STACK_NOT_FOUND",
"No local stacks. Run `honcho start` first.",
)
if len(names) == 1:
if not _status_one(load_profile(names[0])):
raise typer.Exit(1)
return
rows: list[dict] = []
try:
for name in names:
profile = load_profile(name)
services, running = _inspect(profile)
rows.append(
_payload(profile, "running" if running else "stopped", services)
)
except DockerError as e:
e.exit()
if use_json():
print_json({"profiles": rows})
return
_console.print()
print_result(
[
{
"profile": row["profile"],
"status": row["status"],
"api": row["endpoints"]["api"],
}
for row in rows
],
columns=["profile", "status", "api"],
)

View File

@ -0,0 +1,12 @@
"""Local Honcho stack: profiles, Compose rendering, Docker, health checks."""
from __future__ import annotations
DEFAULT_PROFILE = "local"
DEFAULT_API_PORT = 8000
DEFAULT_DB_PORT = 5432
DEFAULT_REDIS_PORT = 6379
DEFAULT_IMAGE = "ghcr.io/plastic-labs/honcho:latest"
DEFAULT_HEALTH_TIMEOUT = 180
STACK_SERVICES = ("api", "deriver", "database", "redis")

View File

@ -0,0 +1,436 @@
"""Docker daemon + Compose helpers for the local stack."""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from honcho_cli.local import STACK_SERVICES
from honcho_cli.local.profile import LocalProfile
from honcho_cli.output import print_error
_DAEMON_DOWN_MARKERS = (
"cannot connect to the docker daemon",
"is the docker daemon running",
"failed to connect to the docker api",
"error during connect",
)
_COMPOSE_MISSING_MARKERS = (
"'compose' is not a docker command",
"unknown command: compose",
"docker: unknown command",
)
_CRED_HELPER_MARKERS = ("error getting credentials", "docker-credential-desktop")
class DockerError(Exception):
"""Docker is missing, the daemon is down, or a Compose command failed."""
def __init__(self, code: str, message: str, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def exit(self) -> None:
print_error(self.code, self.message, self.details or None)
raise SystemExit(1)
def port_available(port: int, host: str = "127.0.0.1") -> bool:
"""True when nothing is accepting connections on ``host:port``."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.2)
return sock.connect_ex((host, port)) != 0
def allocate_host_ports(
profile: LocalProfile,
*,
pinned: frozenset[str] = frozenset(),
) -> tuple[LocalProfile, dict[str, tuple[int, int]]]:
"""Move api/db/redis host ports that are already bound.
Names in ``pinned`` (``api`` / ``database`` / ``redis``) were set by a
flag and fail instead of moving.
"""
taken: set[int] = set()
remapped: dict[str, tuple[int, int]] = {}
chosen: dict[str, int] = {}
for name, field, flag in (
("api", "api_port", "--api-port"),
("database", "db_port", "--db-port"),
("redis", "redis_port", "--redis-port"),
):
preferred = getattr(profile, field)
port = preferred
if name in pinned:
if preferred in taken or not port_available(preferred):
raise DockerError(
"PORT_IN_USE",
f"Host port {preferred} for {name} is already in use. "
f"Pass {flag} with a free port, or stop the other process.",
{"port": preferred, "service": name, "flag": flag},
)
else:
while port in taken or not port_available(port):
port += 1
if port > preferred + 100:
raise DockerError(
"PORT_IN_USE",
f"Could not find a free host port near {preferred}.",
{"preferred": preferred},
)
if port != preferred:
remapped[name] = (preferred, port)
taken.add(port)
chosen[field] = port
return profile.overlay(**chosen), remapped
def compose_argv(profile: LocalProfile) -> list[str]:
return [
"docker",
"compose",
"-f",
str(profile.compose_file()),
"--project-directory",
str(profile.dir()),
"-p",
profile.project_name,
]
_CONFIG_PATHS = ("/app/config.toml.example", "/app/config.toml")
_CONFIG_HEADER = (
"# Copied from {image} by honcho start. This file is not overwritten on later starts.\n"
"# Secrets belong in .env (environment variables win over this file).\n\n"
)
def image_is_digest(ref: str) -> bool:
"""True when ``ref`` is already pinned to a content digest."""
return "@sha256:" in ref.lower()
def image_repository(ref: str) -> str:
"""Strip a tag or digest from a Docker image reference."""
if "@" in ref:
return ref.split("@", 1)[0]
last_slash = ref.rfind("/")
last_colon = ref.rfind(":")
if last_colon > last_slash:
return ref[:last_colon]
return ref
def pin_image(image: str) -> str:
"""Pull ``image`` if needed and return a digest-pinned reference.
``ghcr.io/plastic-labs/honcho:latest`` becomes
``ghcr.io/plastic-labs/honcho@sha256:...`` so the profile does not
float when ``:latest`` moves. Already-pinned refs are left alone.
"""
if image_is_digest(image):
if not _image_exists(image):
_pull(image)
return image
_pull(image)
digest = _repo_digest(image)
if not digest:
raise DockerError(
"IMAGE_PIN_FAILED",
f"Pulled {image} but could not resolve a registry digest to pin.",
{"image": image},
)
return digest
def seed_config_toml(profile: LocalProfile) -> bool:
"""Copy the image's ``config.toml.example`` into the profile if missing.
Returns True when a file was written. Never overwrites an existing
``config.toml``.
"""
dest = profile.config_file()
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
return False
copied = _copy_from_image(profile.image, _CONFIG_PATHS)
if copied is None:
raise DockerError(
"CONFIG_MISSING",
f"Could not copy config.toml from {profile.image}.",
{"image": profile.image},
)
dest.write_text(_CONFIG_HEADER.format(image=profile.image) + copied)
return True
def compose_up(
profile: LocalProfile,
*,
recreate: tuple[str, ...] = (),
) -> None:
"""``docker compose up -d``. Compose output goes to stderr.
``recreate`` names services to ``--force-recreate`` (used after ``--setup``
on an already-running stack so new ``.env`` values take effect).
"""
args = ["up", "-d"]
if recreate:
args.extend(["--force-recreate", *recreate])
_run_compose(profile, args)
def compose_down(profile: LocalProfile, *, wipe: bool = False) -> None:
args = ["down"]
if wipe:
args.append("-v")
_run_compose(profile, args, capture=False)
def compose_ps(profile: LocalProfile) -> list[dict]:
"""Parsed ``docker compose ps --format json`` (array or NDJSON)."""
proc = _run_compose(profile, ["ps", "--format", "json"], capture=True, check=False)
if proc.returncode != 0:
return []
return _parse_ps(proc.stdout or "")
def services_running(ps: list[dict]) -> dict[str, str]:
"""Map service name → state for the four stack services.
State is ``running``, ``healthy``, ``exited``, etc. Prefer Docker's
Health field when present.
"""
out: dict[str, str] = {}
for row in ps:
service = str(row.get("Service") or row.get("Name") or "")
# "honcho-local-api-1" → try Service first; fall back to suffix match
if service not in STACK_SERVICES:
for name in STACK_SERVICES:
if (
service == name
or service.endswith(f"-{name}-1")
or f"_{name}_" in service
):
service = name
break
else:
continue
health = str(row.get("Health") or "").lower()
state = str(row.get("State") or row.get("Status") or "").lower()
if health:
out[service] = health
elif "health" in state:
# e.g. "running (healthy)"
out[service] = state
else:
out[service] = state or "unknown"
return out
def stack_containers_up(ps: list[dict]) -> bool:
"""True when all four services are running (deriver has no healthcheck)."""
states = services_running(ps)
if any(name not in states for name in STACK_SERVICES):
return False
for state in states.values():
if "exit" in state or state in {"dead", "paused"}:
return False
if "running" not in state and "healthy" not in state:
return False
return True
def _unavailable(proc: subprocess.CompletedProcess[str]) -> DockerError | None:
"""Map a failed docker/compose process to a user-facing error, if obvious."""
text = f"{proc.stderr or ''}{proc.stdout or ''}"
lower = text.lower()
if any(marker in lower for marker in _DAEMON_DOWN_MARKERS):
return DockerError(
"DOCKER_NOT_RUNNING",
"Docker is installed but the daemon is not running. Start it and retry.",
)
if any(marker in lower for marker in _COMPOSE_MISSING_MARKERS):
return DockerError(
"DOCKER_COMPOSE_MISSING",
"Honcho start requires Docker Compose v2 (the `docker compose` plugin).",
)
if any(marker in text for marker in _CRED_HELPER_MARKERS):
return DockerError(
"DOCKER_CREDENTIALS",
"Docker could not read registry credentials "
"(docker-credential-desktop is not on PATH). "
"Quit and reopen your terminal, or add Docker Desktop's bin "
"directory to PATH, then retry.",
{"exit_code": proc.returncode},
)
return None
def _run_compose(
profile: LocalProfile,
args: list[str],
*,
capture: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess[str]:
cmd = compose_argv(profile) + args
cwd: Path = profile.dir()
try:
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError as e:
raise DockerError(
"DOCKER_NOT_INSTALLED",
"Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.",
) from e
except OSError as e:
raise DockerError("COMPOSE_FAILED", str(e), {"command": cmd}) from e
if not capture:
if proc.stdout:
sys.stderr.write(proc.stdout)
if proc.stderr:
sys.stderr.write(proc.stderr)
if proc.returncode != 0:
classified = _unavailable(proc)
if classified is not None:
raise classified
if check and proc.returncode != 0:
raise DockerError(
"COMPOSE_FAILED",
"docker compose failed. See output above, or run `docker compose -p "
f"{profile.project_name} logs`.",
{"project": profile.project_name, "exit_code": proc.returncode},
)
return proc
def _run_docker(
args: list[str],
*,
check: bool = False,
) -> subprocess.CompletedProcess[str]:
try:
proc = subprocess.run(
["docker", *args],
capture_output=True,
text=True,
)
except FileNotFoundError as e:
raise DockerError(
"DOCKER_NOT_INSTALLED",
"Docker is not installed. Install Docker Desktop (or another Compose-v2 runtime) and retry.",
) from e
except OSError as e:
raise DockerError("DOCKER_FAILED", str(e), {"command": args}) from e
if proc.returncode == 0:
return proc
classified = _unavailable(proc)
if classified is not None:
raise classified
if check:
raise DockerError(
"DOCKER_FAILED",
f"docker {' '.join(args)} failed.",
{
"exit_code": proc.returncode,
"stderr": (proc.stderr or "")[-500:],
},
)
return proc
def _pull(image: str) -> None:
proc = _run_docker(["pull", image], check=False)
if proc.stdout:
sys.stderr.write(proc.stdout)
if proc.stderr:
sys.stderr.write(proc.stderr)
if proc.returncode != 0:
raise DockerError(
"IMAGE_PULL_FAILED",
f"Failed to pull {image}.",
{"image": image, "exit_code": proc.returncode},
)
def _image_exists(image: str) -> bool:
return _run_docker(["image", "inspect", image], check=False).returncode == 0
def _repo_digest(image: str) -> str | None:
proc = _run_docker(
["image", "inspect", "--format", "{{json .RepoDigests}}", image],
check=False,
)
if proc.returncode != 0:
return None
try:
digests = json.loads((proc.stdout or "").strip() or "[]")
except json.JSONDecodeError:
return None
if not isinstance(digests, list):
return None
repo = image_repository(image)
for item in digests:
if isinstance(item, str) and item.startswith(repo + "@"):
return item
for item in digests:
if isinstance(item, str) and "@sha256:" in item:
return item
return None
def _copy_from_image(image: str, paths: tuple[str, ...]) -> str | None:
"""Create a stopped container and copy the first path that exists."""
name = f"honcho-seed-{os.getpid()}-{time.time_ns()}"
created = _run_docker(["create", "--name", name, image], check=False)
if created.returncode != 0:
cid = (created.stdout or "").strip() or name
_run_docker(["rm", "-f", cid], check=False)
return None
cid = (created.stdout or "").strip() or name
try:
with tempfile.TemporaryDirectory(prefix="honcho-cfg-") as tmp:
dest = Path(tmp) / "config.toml"
for path in paths:
if dest.exists():
dest.unlink()
copied = _run_docker(["cp", f"{cid}:{path}", str(dest)], check=False)
if copied.returncode == 0 and dest.exists():
return dest.read_text(encoding="utf-8")
finally:
_run_docker(["rm", "-f", cid], check=False)
return None
def _parse_ps(stdout: str) -> list[dict]:
text = stdout.strip()
if not text:
return []
if text.startswith("["):
try:
data = json.loads(text)
except json.JSONDecodeError:
return []
return data if isinstance(data, list) else []
rows: list[dict] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row, dict):
rows.append(row)
return rows

View File

@ -0,0 +1,183 @@
"""Render Compose + ``.env`` for a local stack profile."""
from __future__ import annotations
import os
from contextlib import suppress
from importlib.resources import files
from pathlib import Path
from honcho_cli.local.profile import LocalProfile
# Keys honcho start owns. Unknown lines in an existing .env are preserved.
MANAGED_KEYS = (
"AUTH_USE_AUTH",
"LOG_LEVEL",
"HONCHO_IMAGE",
"API_PORT",
"DB_PORT",
"REDIS_PORT",
)
# Host env forwarded into the profile .env (overrides config.toml).
_SETTINGS_PREFIXES = (
"LLM_",
"EMBEDDING_",
"DERIVER_",
"DIALECTIC_",
"DREAM_",
"SUMMARY_",
)
_LLM_KEYS = (
"LLM_OPENAI_API_KEY",
"LLM_ANTHROPIC_API_KEY",
"LLM_GEMINI_API_KEY",
)
_HEADER = (
"# Generated by honcho start. Extra keys below the managed block are preserved."
)
_PLACEHOLDERS = frozenset(
{
"",
"your-api-key-here",
"changeme",
"sk-...",
}
)
def is_placeholder_key(value: str | None) -> bool:
"""True when ``value`` is missing or a known template placeholder."""
if value is None:
return True
return value.strip() in _PLACEHOLDERS
def settings_from_environ() -> dict[str, str]:
"""Host env vars that map to Honcho settings. Empty/placeholder values skipped."""
return {
k: v
for k, v in os.environ.items()
if k.startswith(_SETTINGS_PREFIXES) and not is_placeholder_key(v)
}
def read_env_file(path: Path) -> dict[str, str]:
"""Parse a dotenv file into a dict. Last assignment of a key wins."""
if not path.exists():
return {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return {}
out: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
k, _, v = stripped.partition("=")
out[k.strip()] = _unquote(v.strip())
return out
def read_env_value(path: Path, key: str) -> str | None:
"""Return the raw value for ``key`` in a dotenv file, or None."""
return read_env_file(path).get(key)
def has_provider_key(profile: LocalProfile, extra: dict[str, str]) -> bool:
"""True when host extra or profile ``.env`` has a real LLM API key."""
stored = {**read_env_file(profile.env_file()), **extra}
return any(not is_placeholder_key(stored.get(k)) for k in _LLM_KEYS)
def managed_env(profile: LocalProfile) -> dict[str, str]:
"""Values written into the managed block of ``.env``."""
return {
"AUTH_USE_AUTH": "false",
"LOG_LEVEL": "INFO",
"HONCHO_IMAGE": profile.image,
"API_PORT": str(profile.api_port),
"DB_PORT": str(profile.db_port),
"REDIS_PORT": str(profile.redis_port),
}
def upsert_env(
path: Path,
updates: dict[str, str],
*,
drop: tuple[str, ...] = (),
) -> None:
"""Write ``updates``, preserving unrelated user lines.
Managed keys are written first (stable order), then any other keys in
``updates``. Keys in ``drop`` are removed and not rewritten. Drops a
previous generated header so it is not duplicated.
"""
drop_set = frozenset(drop)
extras: list[str] = []
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped == _HEADER or stripped.startswith(
"# Generated by honcho start"
):
continue
if not stripped or stripped.startswith("#"):
extras.append(line)
continue
if "=" in stripped:
k, _, _ = stripped.partition("=")
name = k.strip()
if name in updates or name in MANAGED_KEYS or name in drop_set:
continue
extras.append(line)
managed = [f"{k}={updates[k]}" for k in MANAGED_KEYS if k in updates]
extra_updates = [
f"{k}={v}" for k, v in updates.items() if k not in MANAGED_KEYS
]
body = [_HEADER, *managed]
if extra_updates:
if not body[-1].startswith("#"):
body.append("")
body.extend(extra_updates)
if extras:
# Keep a blank line between generated and user keys when there are extras.
if extras[0].strip():
body.append("")
body.extend(extras)
path.write_text("\n".join(body) + "\n")
with suppress(OSError):
os.chmod(path, 0o600)
def render_stack(
profile: LocalProfile,
extra: dict[str, str] | None = None,
drop: tuple[str, ...] = (),
) -> None:
"""Write compose, init.sql, and .env into the profile directory."""
directory = profile.dir()
directory.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
templates = files("honcho_cli.local.templates")
compose = templates.joinpath("docker-compose.yml").read_text(encoding="utf-8")
init_sql = templates.joinpath("init.sql").read_text(encoding="utf-8")
profile.compose_file().write_text(compose)
(directory / "init.sql").write_text(init_sql)
updates = managed_env(profile)
if extra:
updates.update(extra)
upsert_env(profile.env_file(), updates, drop=drop)
def _unquote(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value

View File

@ -0,0 +1,53 @@
"""Poll the local API health endpoint."""
from __future__ import annotations
import time
import httpx
from honcho_cli.local.docker import compose_ps, services_running, stack_containers_up
from honcho_cli.local.profile import LocalProfile
def api_healthy(base_url: str, *, timeout: float = 2.0) -> bool:
"""True when ``GET /health`` returns HTTP 200."""
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(base_url.rstrip("/") + "/health")
return response.status_code == 200
except httpx.HTTPError:
return False
def stack_healthy(profile: LocalProfile) -> bool:
"""True when Compose services are up and the API answers /health."""
if not profile.compose_file().exists():
return False
ps = compose_ps(profile)
if not stack_containers_up(ps):
return False
return api_healthy(profile.base_url)
def wait_for_health(
profile: LocalProfile,
*,
timeout: float,
interval: float = 1.0,
) -> bool:
"""Poll until the API is healthy or ``timeout`` seconds elapse.
Returns False on timeout. Fails fast if a required container has exited.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
ps = compose_ps(profile)
states = services_running(ps)
for _name, state in states.items():
if "exit" in state or state in {"dead"}:
return False
if api_healthy(profile.base_url) and stack_containers_up(ps):
return True
time.sleep(interval)
return api_healthy(profile.base_url)

View File

@ -0,0 +1,157 @@
"""Named local-stack profiles under ``$HONCHO_CONFIG_DIR/profiles``.
A profile is a Compose project directory, not an auth identity.
Resolution: ``--profile`` > ``HONCHO_PROFILE`` > ``local``.
"""
from __future__ import annotations
import json
import os
import re
from contextlib import suppress
from dataclasses import dataclass, replace
from honcho_cli.local import (
DEFAULT_API_PORT,
DEFAULT_DB_PORT,
DEFAULT_IMAGE,
DEFAULT_PROFILE,
DEFAULT_REDIS_PORT,
)
from honcho_cli.output import print_error
_PROFILE_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
def profiles_dir():
from honcho_cli import config as cfg
return cfg.CONFIG_DIR / "profiles"
def validate_profile_name(name: str) -> str:
if name and _PROFILE_NAME.match(name):
return name
print_error(
"INVALID_PROFILE",
"Profile name must be lowercase alphanumeric, starting with a letter "
"(hyphens and underscores allowed).",
{"profile": name},
)
raise SystemExit(1)
def resolve_profile_name(flag: str | None) -> str:
raw = (
(flag or "").strip()
or (os.environ.get("HONCHO_PROFILE") or "").strip()
or DEFAULT_PROFILE
)
return validate_profile_name(raw)
def list_profile_names() -> list[str]:
"""Profile directories that already have a Compose file."""
root = profiles_dir()
if not root.is_dir():
return []
names: list[str] = []
for path in sorted(root.iterdir()):
if (
path.is_dir()
and _PROFILE_NAME.match(path.name)
and (path / "docker-compose.yml").exists()
):
names.append(path.name)
return names
@dataclass
class LocalProfile:
"""Ports and image for one local stack."""
name: str
api_port: int = DEFAULT_API_PORT
db_port: int = DEFAULT_DB_PORT
redis_port: int = DEFAULT_REDIS_PORT
image: str = DEFAULT_IMAGE
@property
def project_name(self) -> str:
return f"honcho-{self.name}"
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.api_port}"
def dir(self):
return profiles_dir() / self.name
def compose_file(self):
return self.dir() / "docker-compose.yml"
def env_file(self):
return self.dir() / ".env"
def profile_file(self):
return self.dir() / "profile.json"
def config_file(self):
return self.dir() / "config.toml"
def endpoints(self) -> dict[str, str]:
return {
"api": self.base_url,
"docs": f"{self.base_url}/docs",
"postgres": f"postgresql://postgres:postgres@127.0.0.1:{self.db_port}/postgres",
"redis": f"redis://127.0.0.1:{self.redis_port}/0",
}
def overlay(self, **fields) -> LocalProfile:
return replace(self, **{k: v for k, v in fields.items() if v is not None})
def load_profile(name: str) -> LocalProfile:
profile = LocalProfile(name=validate_profile_name(name))
path = profile.profile_file()
if not path.exists():
return profile
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return profile
if not isinstance(data, dict):
return profile
image = data.get("image")
return replace(
profile,
api_port=_port(data.get("apiPort"), profile.api_port),
db_port=_port(data.get("dbPort"), profile.db_port),
redis_port=_port(data.get("redisPort"), profile.redis_port),
image=image if isinstance(image, str) and image else profile.image,
)
def save_profile(profile: LocalProfile) -> None:
directory = profile.dir()
directory.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
os.chmod(directory, 0o700)
payload = {
"apiPort": profile.api_port,
"dbPort": profile.db_port,
"redisPort": profile.redis_port,
"image": profile.image,
}
profile.profile_file().write_text(json.dumps(payload, indent=2) + "\n")
def _port(value: object, default: int) -> int:
if isinstance(value, bool):
return default
try:
parsed = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return parsed if 1 <= parsed <= 65535 else default

View File

@ -0,0 +1,531 @@
"""Interactive ``honcho start --setup`` wizard.
Writes curated LLM/feature overrides for the local stack. Secrets and knobs
go to the profile ``.env`` (env wins over ``config.toml``). Prompts are TTY
only the start command rejects ``--setup`` in JSON / non-TTY mode.
"""
from __future__ import annotations
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
import typer
from rich.console import Console
from honcho_cli.local.env import is_placeholder_key, read_env_file, settings_from_environ
from honcho_cli.output import print_error
SETUP_MODES = ("basic", "advanced")
DIALECTIC_LEVELS = ("minimal", "low", "medium", "high", "max")
PROVIDERS = ("openai", "anthropic", "gemini", "openai-compatible")
EMBEDDING_TRANSPORTS = ("openai", "gemini")
_CHAT_PREFIXES = (
"DERIVER_MODEL_CONFIG",
"SUMMARY_MODEL_CONFIG",
"DREAM_DEDUCTION_MODEL_CONFIG",
"DREAM_INDUCTION_MODEL_CONFIG",
*(f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG" for level in DIALECTIC_LEVELS),
)
_PROVIDER_KEY_ENV = {
"openai": "LLM_OPENAI_API_KEY",
"openai-compatible": "LLM_OPENAI_API_KEY",
"anthropic": "LLM_ANTHROPIC_API_KEY",
"gemini": "LLM_GEMINI_API_KEY",
}
_console = Console(stderr=True)
@dataclass(frozen=True)
class TomlSetupDefaults:
"""Model/feature defaults copied from the image ``config.toml``.
Honcho only ships OpenAI chat/embedding defaults. Other providers have
no suggested model in that file the wizard does not invent one.
"""
chat_transport: str | None = None
chat_model: str | None = None
embed_transport: str | None = None
embed_model: str | None = None
embed_dims: int | None = None
dreams_enabled: bool | None = None
flush_enabled: bool | None = None
def load_toml_setup_defaults(path: Path | None) -> TomlSetupDefaults:
"""Read prompt defaults from the profile ``config.toml`` (image-aligned)."""
if path is None or not path.is_file():
return TomlSetupDefaults()
try:
with path.open("rb") as fh:
data = tomllib.load(fh)
deriver = data.get("deriver") or {}
chat = deriver.get("model_config") or {}
embedding = data.get("embedding") or {}
embed = embedding.get("model_config") or {}
dream = data.get("dream") or {}
dims = embedding.get("VECTOR_DIMENSIONS")
return TomlSetupDefaults(
chat_transport=chat.get("transport"),
chat_model=chat.get("model"),
embed_transport=embed.get("transport"),
embed_model=embed.get("model"),
embed_dims=dims if isinstance(dims, int) and dims > 0 else None,
dreams_enabled=dream.get("ENABLED"),
flush_enabled=deriver.get("FLUSH_ENABLED"),
)
except (OSError, tomllib.TOMLDecodeError, TypeError, AttributeError):
return TomlSetupDefaults()
def chat_model_default(
provider: str,
env: dict[str, str],
toml: TomlSetupDefaults,
*,
inferred: str | None = None,
) -> str:
"""Prefer a previous wizard choice, else the image toml when transports match."""
if inferred is None:
inferred = infer_provider(env)
if inferred == provider:
current = env.get("DERIVER_MODEL_CONFIG__MODEL")
if current:
return current
if toml.chat_model and _provider_matches_transport(provider, toml.chat_transport):
return toml.chat_model
return ""
def _provider_matches_transport(provider: str, transport: str | None) -> bool:
if not transport:
return False
return transport_of(provider) == transport
@dataclass(frozen=True)
class SetupAnswers:
"""Curated knobs collected by the wizard (or tests)."""
mode: str
provider: str
api_key: str
chat_model: str
base_url: str | None = None
embedding_api_key: str | None = None
embedding_key_transport: str | None = None
embedding_transport: str | None = None
embedding_model: str | None = None
embedding_dimensions: int | None = None
deriver_model: str | None = None
dialectic_model: str | None = None
dreams_enabled: bool | None = None
flush_enabled: bool | None = None
def transport_of(provider: str) -> str:
"""Honcho ``MODEL_CONFIG.transport`` for a wizard provider id."""
return "openai" if provider == "openai-compatible" else provider
def answers_to_env(answers: SetupAnswers) -> dict[str, str]:
"""Map wizard answers to Honcho env overrides."""
transport = transport_of(answers.provider)
env: dict[str, str] = {}
env[_PROVIDER_KEY_ENV[answers.provider]] = answers.api_key
if answers.base_url:
env["LLM_OPENAI_BASE_URL"] = answers.base_url
# Embeddings do not inherit this URL; write it so OpenRouter/vLLM
# keys are not sent to api.openai.com.
if (answers.embedding_transport or "openai") == "openai":
env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"] = answers.base_url
if answers.embedding_api_key and answers.embedding_key_transport:
embed_key = (
"LLM_OPENAI_API_KEY"
if answers.embedding_key_transport == "openai"
else "LLM_GEMINI_API_KEY"
)
env[embed_key] = answers.embedding_api_key
for prefix in _CHAT_PREFIXES:
env[f"{prefix}__TRANSPORT"] = transport
env[f"{prefix}__MODEL"] = answers.chat_model
if answers.deriver_model:
env["DERIVER_MODEL_CONFIG__TRANSPORT"] = transport
env["DERIVER_MODEL_CONFIG__MODEL"] = answers.deriver_model
if answers.dialectic_model:
for level in DIALECTIC_LEVELS:
env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__TRANSPORT"] = transport
env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] = (
answers.dialectic_model
)
if answers.embedding_transport:
env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = answers.embedding_transport
if answers.embedding_model:
env["EMBEDDING_MODEL_CONFIG__MODEL"] = answers.embedding_model
if answers.embedding_dimensions is not None:
env["EMBEDDING_VECTOR_DIMENSIONS"] = str(answers.embedding_dimensions)
elif answers.embedding_key_transport == "gemini":
# Basic + Anthropic chat: a Gemini key is unused unless embeddings switch.
env["EMBEDDING_MODEL_CONFIG__TRANSPORT"] = "gemini"
if answers.dreams_enabled is not None:
env["DREAM_ENABLED"] = "true" if answers.dreams_enabled else "false"
if answers.flush_enabled is not None:
env["DERIVER_FLUSH_ENABLED"] = "true" if answers.flush_enabled else "false"
return env
def answers_drop_keys(answers: SetupAnswers) -> tuple[str, ...]:
"""Keys to remove so a previous wizard run cannot leak into this one."""
drop: list[str] = []
if answers.provider != "openai-compatible":
drop.append("LLM_OPENAI_BASE_URL")
embed_openai = (answers.embedding_transport or "openai") == "openai"
if answers.provider != "openai-compatible" or not embed_openai:
drop.append("EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL")
return tuple(drop)
def run_setup(
mode: str,
env_path: Path,
*,
config_path: Path | None = None,
) -> SetupAnswers:
"""Prompt for ``basic`` or ``advanced`` knobs. Enter keeps the default."""
env = read_env_file(env_path)
env.update(settings_from_environ())
defaults = load_toml_setup_defaults(config_path)
_console.print()
_console.print(
" [dim]Configure the local stack. Press Enter to keep the default.[/dim]"
)
_console.print(
" [dim]These values go in .env (they override config.toml).[/dim]"
)
_console.print()
inferred = infer_provider(env)
provider = _choose(
"LLM provider",
[
("openai", "OpenAI"),
("anthropic", "Anthropic"),
("gemini", "Gemini"),
("openai-compatible", "OpenAI-compatible (OpenRouter, vLLM, Ollama, …)"),
],
inferred if inferred in PROVIDERS else "openai",
)
base_url: str | None = None
if provider == "openai-compatible":
base_url = _prompt_text(
"OpenAI-compatible base URL",
env.get("LLM_OPENAI_BASE_URL") or "https://openrouter.ai/api/v1",
)
key_env = _PROVIDER_KEY_ENV[provider]
api_key = _prompt_secret("API key", env.get(key_env))
chat_default = chat_model_default(
provider, env, defaults, inferred=inferred
)
chat_model = _prompt_text(
"Chat model (deriver, dialectic, summary, dream)",
chat_default,
required=True,
)
embedding_api_key: str | None = None
embedding_key_transport: str | None = None
embedding_transport: str | None = None
embedding_model: str | None = None
embedding_dimensions: int | None = None
deriver_model: str | None = None
dialectic_model: str | None = None
dreams_enabled: bool | None = None
flush_enabled: bool | None = None
if mode == "advanced":
embedding_transport = _choose(
"Embedding provider",
[("openai", "OpenAI"), ("gemini", "Gemini")],
_default_embedding_transport(provider, env, defaults),
)
same_embed = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT") == embedding_transport
current_embed = env.get("EMBEDDING_MODEL_CONFIG__MODEL") if same_embed else None
embed_from_toml = (
defaults.embed_model
if defaults.embed_transport == embedding_transport
else None
)
embedding_model = (
_prompt_text("Embedding model", current_embed or embed_from_toml or "")
or None
)
dim_default = (
int(env["EMBEDDING_VECTOR_DIMENSIONS"])
if env.get("EMBEDDING_VECTOR_DIMENSIONS", "").isdigit()
else (defaults.embed_dims or 1536)
)
embedding_dimensions = _prompt_int("Embedding dimensions", dim_default)
embedding_key_transport, embedding_api_key = _embedding_key_if_needed(
provider, embedding_transport, env
)
deriver_model = _prompt_text("Deriver model", chat_model)
dialectic_model = _prompt_text("Dialectic model (all reasoning levels)", chat_model)
dreams_enabled = _choose_bool(
"Dreams (periodic deeper reasoning)",
_env_bool(
env.get("DREAM_ENABLED"),
default=True if defaults.dreams_enabled is None else defaults.dreams_enabled,
),
)
flush_enabled = _choose_bool(
"Snappy local deriver (flush work immediately, skip batching)",
_env_bool(
env.get("DERIVER_FLUSH_ENABLED"),
default=False if defaults.flush_enabled is None else defaults.flush_enabled,
),
)
elif provider == "anthropic":
embedding_key_transport = _choose(
"Embeddings (Anthropic has none — pick a provider)",
[("openai", "OpenAI"), ("gemini", "Gemini")],
"openai",
)
embed_key_env = _PROVIDER_KEY_ENV[
"openai" if embedding_key_transport == "openai" else "gemini"
]
embedding_api_key = _prompt_secret("Embedding API key", env.get(embed_key_env))
_console.print()
return SetupAnswers(
mode=mode,
provider=provider,
api_key=api_key,
chat_model=chat_model,
base_url=base_url,
embedding_api_key=embedding_api_key,
embedding_key_transport=embedding_key_transport,
embedding_transport=embedding_transport,
embedding_model=embedding_model,
embedding_dimensions=embedding_dimensions,
deriver_model=deriver_model,
dialectic_model=dialectic_model,
dreams_enabled=dreams_enabled,
flush_enabled=flush_enabled,
)
def infer_provider(env: dict[str, str]) -> str:
"""Best-effort provider from an existing profile ``.env``."""
if env.get("LLM_OPENAI_BASE_URL"):
return "openai-compatible"
transport = env.get("DERIVER_MODEL_CONFIG__TRANSPORT")
if transport in ("anthropic", "gemini", "openai"):
return transport
if env.get("LLM_ANTHROPIC_API_KEY") and not env.get("LLM_OPENAI_API_KEY"):
return "anthropic"
if env.get("LLM_GEMINI_API_KEY") and not env.get("LLM_OPENAI_API_KEY"):
return "gemini"
return "openai"
def _default_embedding_transport(
provider: str, env: dict[str, str], defaults: TomlSetupDefaults
) -> str:
current = env.get("EMBEDDING_MODEL_CONFIG__TRANSPORT")
if current in EMBEDDING_TRANSPORTS:
return current
if defaults.embed_transport in EMBEDDING_TRANSPORTS:
return defaults.embed_transport
if provider == "gemini":
return "gemini"
return "openai"
def _embedding_key_if_needed(
chat_provider: str,
embed_transport: str,
env: dict[str, str],
) -> tuple[str | None, str | None]:
"""Prompt for an embedding key when the chat provider cannot supply it."""
chat_transport = transport_of(chat_provider)
if embed_transport == chat_transport or (
chat_provider == "openai-compatible" and embed_transport == "openai"
):
return None, None
key_env = _PROVIDER_KEY_ENV[embed_transport]
key = _prompt_secret(f"{embed_transport} embedding API key", env.get(key_env))
return embed_transport, key
def _choose(label: str, options: list[tuple[str, str]], default: str) -> str:
ids = [item[0] for item in options]
default_idx = ids.index(default) + 1 if default in ids else 1
_console.print(f" [dim]{label}[/dim]")
for i, (_oid, desc) in enumerate(options, 1):
_console.print(f" [dim]({i})[/dim] {desc}")
raw = typer.prompt(
" Choice",
default=str(default_idx),
show_default=True,
prompt_suffix=": ",
).strip()
try:
idx = int(raw)
except ValueError:
if raw in ids:
return raw
return options[default_idx - 1][0]
if 1 <= idx <= len(options):
return options[idx - 1][0]
return options[default_idx - 1][0]
def _choose_bool(label: str, default: bool) -> bool:
return (
_choose(label, [("true", "On"), ("false", "Off")], "true" if default else "false")
== "true"
)
def _prompt_text(label: str, default: str, *, required: bool = False) -> str:
while True:
raw = typer.prompt(
f" {label}",
default=default,
show_default=bool(default),
prompt_suffix=": ",
).strip()
value = raw or default
if value or not required:
return value
_console.print(" [red]A model name is required[/red]")
def _prompt_int(label: str, default: int) -> int:
while True:
raw = typer.prompt(
f" {label}",
default=str(default),
show_default=True,
prompt_suffix=": ",
).strip()
try:
value = int(raw)
except ValueError:
_console.print(" [red]Enter an integer[/red]")
continue
if value > 0:
return value
_console.print(" [red]Must be a positive integer[/red]")
def _prompt_secret(label: str, current: str | None) -> str:
if current and not is_placeholder_key(current):
_console.print(f" [dim]Current {label}: {_redact(current)}[/dim]")
_console.print(" [dim](1)[/dim] Keep current key")
_console.print(" [dim](2)[/dim] Enter a new key")
choice = typer.prompt(
" Choice", default="1", show_default=True, prompt_suffix=": "
).strip()
if choice != "2":
return current
_console.print(f" [dim]{label}[/dim]")
raw = _prompt_masked(f" {label}: ").strip()
if not raw or is_placeholder_key(raw):
print_error(
"MISSING_LLM_KEY",
f"{label} is required.",
)
raise typer.Exit(1)
return raw
def _prompt_masked(prompt: str) -> str:
"""Read a secret, echoing ``*`` per character so paste is visibly received."""
stream = sys.stderr
stream.write(prompt)
stream.flush()
chars: list[str] = []
def _write(text: str) -> None:
stream.write(text)
stream.flush()
def _feed(ch: str) -> bool:
"""Return True when input is complete."""
if not ch or ch in ("\n", "\r", "\x04"):
_write("\n")
return True
if ch in ("\x7f", "\x08"):
if chars:
chars.pop()
_write("\b \b")
return False
if ch == "\x1b":
return False
if ch.isprintable():
chars.append(ch)
_write("*")
return False
if sys.platform == "win32":
import msvcrt
while True:
ch = msvcrt.getwch()
if ch in ("\x00", "\xe0"):
msvcrt.getwch()
continue
if _feed(ch):
return "".join(chars)
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
while True:
ch = sys.stdin.read(1)
if ch == "\x1b":
nxt = sys.stdin.read(1)
if nxt == "[":
while True:
seq = sys.stdin.read(1)
if not seq or "@" <= seq <= "~":
break
continue
if _feed(ch):
return "".join(chars)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
return "".join(chars)
def _redact(key: str) -> str:
if len(key) <= 4:
return "***"
return "***" + key[-4:]
def _env_bool(value: str | None, *, default: bool) -> bool:
if value is None:
return default
return value.strip().lower() in ("1", "true", "yes", "on")

View File

@ -0,0 +1 @@
"""Package data for the local stack (Compose template + Postgres init)."""

View File

@ -0,0 +1,100 @@
# Managed by `honcho start`. Re-rendered on every start — edit .env and config.toml, not this file.
#
# Images: ghcr.io/plastic-labs/honcho (API + deriver), pgvector/pgvector:pg15, redis:8.2
# Ports bind to 127.0.0.1. Auth is off (AUTH_USE_AUTH=false in .env).
services:
api:
image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest}
entrypoint: ["sh", "docker/entrypoint.sh"]
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "127.0.0.1:${API_PORT:-8000}:8000"
healthcheck:
test:
[
"CMD",
"/app/.venv/bin/python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()",
]
interval: 5s
timeout: 5s
retries: 5
start_period: 10s
volumes:
- lancedb-data:/app/lancedb_data
- ./config.toml:/app/config.toml:ro
environment:
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
- CACHE_URL=redis://redis:6379/0?suppress=true
- CACHE_ENABLED=true
env_file:
- path: .env
required: false
restart: unless-stopped
deriver:
image: ${HONCHO_IMAGE:-ghcr.io/plastic-labs/honcho:latest}
entrypoint: ["/app/.venv/bin/python", "-m", "src.deriver"]
depends_on:
api:
condition: service_healthy
database:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- lancedb-data:/app/lancedb_data
- ./config.toml:/app/config.toml:ro
environment:
- DB_CONNECTION_URI=postgresql+psycopg://postgres:postgres@database:5432/postgres
- CACHE_URL=redis://redis:6379/0?suppress=true
- CACHE_ENABLED=true
env_file:
- path: .env
required: false
restart: unless-stopped
database:
image: pgvector/pgvector:pg15
restart: unless-stopped
ports:
- "127.0.0.1:${DB_PORT:-5432}:5432"
command: ["postgres", "-c", "max_connections=200"]
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST_AUTH_METHOD=trust
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
- pgdata:/var/lib/postgresql/data/
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8.2
restart: unless-stopped
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
redis-data:
lancedb-data:

View File

@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS vector;

View File

@ -15,6 +15,7 @@ from honcho_cli import __version__
from honcho_cli._help import HonchoTyperGroup, print_welcome
from honcho_cli.branding import BANNER
from honcho_cli.output import set_json_mode
from honcho_cli.update_check import maybe_print_update_nag
app = typer.Typer(
@ -60,13 +61,18 @@ def main(
if ctx.invoked_subcommand is None:
print_welcome(Console())
raise typer.Exit()
maybe_print_update_nag()
# Register top-level commands
from honcho_cli.commands.setup import doctor, init
from honcho_cli.commands.stack import start, status, stop
app.command()(init)
app.command()(doctor)
app.command()(start)
app.command()(stop)
app.command()(status)
@app.command("help", hidden=True)

View File

@ -15,6 +15,8 @@ from rich.console import Console
from rich.table import Table
from rich.text import Text
from honcho_cli.branding import ICON_FAIL, ICON_OK, ICON_RUN
console = Console(stderr=True)
stdout_console = Console()
@ -106,6 +108,24 @@ def status(msg: str) -> None:
console.print(f"[dim]{msg}[/dim]")
def step(msg: str) -> None:
"""Print a progress step. No-op in JSON mode."""
if not use_json():
console.print(f" {ICON_RUN} {msg}")
def ok(msg: str) -> None:
"""Print a success line. No-op in JSON mode."""
if not use_json():
console.print(f" {ICON_OK} {msg}")
def fail(msg: str) -> None:
"""Print a failure line. No-op in JSON mode."""
if not use_json():
console.print(f" {ICON_FAIL} {msg}")
# Stable peer-color palette for transcript rendering. Brand blue first so the
# primary peer lands on brand when there's only one speaker.
_PEER_COLORS = (

View File

@ -0,0 +1,67 @@
"""Once-a-day stderr notice when a newer honcho-cli is on PyPI.
Fail-open: any error is swallowed. Cache is ``update-check.json`` beside
config, not ``config.json``.
"""
from __future__ import annotations
import json
import os
import sys
import time
import httpx
from honcho_cli import __version__
from honcho_cli.branding import ICON_RUN
from honcho_cli.config import _config_dir
from honcho_cli.output import console, use_json
_INTERVAL_S = 24 * 60 * 60
_PYPI_URL = "https://pypi.org/pypi/honcho-cli/json"
def maybe_print_update_nag() -> None:
if use_json() or "--json" in sys.argv:
return
if os.environ.get("HONCHO_NO_UPDATE_CHECK", "").lower() in ("1", "true"):
return
try:
path = _config_dir() / "update-check.json"
now = time.time()
try:
cache = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
cache = {}
if isinstance(cache, dict) and now - float(cache.get("t") or 0) < _INTERVAL_S:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"t": now}), encoding="utf-8")
latest = httpx.get(_PYPI_URL, timeout=1.0).json()["info"]["version"]
if not isinstance(latest, str) or not _is_newer(latest, __version__):
return
console.print(f" {ICON_RUN} honcho-cli {latest} is available (you have {__version__})")
console.print(" [dim]uv tool upgrade honcho-cli[/dim]")
except Exception:
return
def _is_newer(latest: str, current: str) -> bool:
def parts(version: str) -> tuple[int, ...]:
out: list[int] = []
for segment in version.lstrip("v").split("."):
num = ""
for ch in segment:
if ch.isdigit():
num += ch
else:
break
if not num:
break
out.append(int(num))
return tuple(out) or (0,)
a, b = parts(latest), parts(current)
n = max(len(a), len(b))
return a + (0,) * (n - len(a)) > b + (0,) * (n - len(b))

View File

@ -0,0 +1,127 @@
"""Local-stack contracts: profile files, env merge, image pin, port remap."""
from __future__ import annotations
import json
import os
import subprocess
import pytest
from honcho_cli.local.docker import (
DockerError,
allocate_host_ports,
pin_image,
seed_config_toml,
)
from honcho_cli.local.env import managed_env, read_env_value, render_stack, upsert_env
from honcho_cli.local.profile import LocalProfile, load_profile, save_profile
@pytest.fixture
def cfg_dir(tmp_path, monkeypatch):
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", tmp_path / "config.json")
for k in [k for k in os.environ if k.startswith("HONCHO_")]:
monkeypatch.delenv(k)
return tmp_path
def test_profile_roundtrip_has_no_secrets(cfg_dir):
profile = LocalProfile(
name="local",
api_port=8001,
image="ghcr.io/plastic-labs/honcho@sha256:abc",
)
save_profile(profile)
loaded = load_profile("local")
assert loaded.api_port == 8001
assert loaded.image.endswith("@sha256:abc")
on_disk = json.loads(profile.profile_file().read_text())
assert "LLM" not in json.dumps(on_disk)
assert set(on_disk) == {"apiPort", "dbPort", "redisPort", "image"}
def test_upsert_preserves_extra_env_keys(tmp_path):
path = tmp_path / ".env"
path.write_text("CUSTOM_FLAG=keep-me\n# user comment\n")
upsert_env(path, managed_env(LocalProfile(name="local")))
text = path.read_text()
assert "CUSTOM_FLAG=keep-me" in text
assert "user comment" in text
assert text.count("Generated by honcho start") == 1
def test_upsert_writes_non_managed_and_preserves_later(tmp_path):
path = tmp_path / ".env"
first = managed_env(LocalProfile(name="local"))
first["DERIVER_MODEL_CONFIG__MODEL"] = "gpt-test"
upsert_env(path, first)
upsert_env(path, managed_env(LocalProfile(name="local")))
later = path.read_text()
assert "DERIVER_MODEL_CONFIG__MODEL=gpt-test" in later
def test_render_stack_uses_published_image(cfg_dir):
profile = LocalProfile(name="local")
render_stack(profile)
compose = profile.compose_file().read_text()
assert "ghcr.io/plastic-labs/honcho" in compose
assert "build:" not in compose
assert compose.count("./config.toml:/app/config.toml:ro") == 2
assert read_env_value(profile.env_file(), "AUTH_USE_AUTH") == "false"
assert oct(profile.env_file().stat().st_mode)[-3:] == "600"
def test_pin_latest_to_matching_digest(monkeypatch):
pulls: list[str] = []
def fake_run(args, *, check=False):
if args[:1] == ["pull"]:
pulls.append(args[1])
return subprocess.CompletedProcess(args, 0, stdout="", stderr="")
if args[:2] == ["image", "inspect"]:
body = json.dumps(
[
"ghcr.io/plastic-labs/honcho@sha256:deadbeef",
"ghcr.io/other/honcho@sha256:nope",
]
)
return subprocess.CompletedProcess(args, 0, stdout=body, stderr="")
raise AssertionError(args)
monkeypatch.setattr("honcho_cli.local.docker._run_docker", fake_run)
assert pin_image("ghcr.io/plastic-labs/honcho:latest") == (
"ghcr.io/plastic-labs/honcho@sha256:deadbeef"
)
assert pulls == ["ghcr.io/plastic-labs/honcho:latest"]
def test_seed_config_toml_writes_once(cfg_dir, monkeypatch):
profile = LocalProfile(
name="local", image="ghcr.io/plastic-labs/honcho@sha256:abc"
)
monkeypatch.setattr(
"honcho_cli.local.docker._copy_from_image",
lambda image, paths: "[deriver]\nWORKERS = 2\n",
)
assert seed_config_toml(profile) is True
profile.config_file().write_text(
profile.config_file().read_text() + "# user edit\n"
)
assert seed_config_toml(profile) is False
assert "# user edit" in profile.config_file().read_text()
def test_busy_port_remaps_unless_pinned(monkeypatch):
monkeypatch.setattr(
"honcho_cli.local.docker.port_available",
lambda port, host="127.0.0.1": port != 6379,
)
profile, remapped = allocate_host_ports(LocalProfile(name="local"))
assert profile.redis_port == 6380
assert remapped["redis"] == (6379, 6380)
with pytest.raises(DockerError) as exc:
allocate_host_ports(LocalProfile(name="local"), pinned=frozenset({"redis"}))
assert exc.value.code == "PORT_IN_USE"
assert exc.value.details["flag"] == "--redis-port"

View File

@ -0,0 +1,94 @@
"""Wizard mapping: ``answers_to_env`` and image-toml defaults."""
from __future__ import annotations
from honcho_cli.local.setup import (
DIALECTIC_LEVELS,
SetupAnswers,
answers_drop_keys,
answers_to_env,
chat_model_default,
load_toml_setup_defaults,
)
def test_basic_openai_applies_chat_model_everywhere():
env = answers_to_env(
SetupAnswers(
mode="basic",
provider="openai",
api_key="sk-test",
chat_model="gpt-test",
)
)
assert env["LLM_OPENAI_API_KEY"] == "sk-test"
assert env["DERIVER_MODEL_CONFIG__MODEL"] == "gpt-test"
assert env["SUMMARY_MODEL_CONFIG__MODEL"] == "gpt-test"
for level in DIALECTIC_LEVELS:
assert env[f"DIALECTIC_LEVELS__{level}__MODEL_CONFIG__MODEL"] == "gpt-test"
assert "DREAM_ENABLED" not in env
assert "EMBEDDING_MODEL_CONFIG__MODEL" not in env
def test_basic_anthropic_keeps_openai_embeddings_default():
env = answers_to_env(
SetupAnswers(
mode="basic",
provider="anthropic",
api_key="sk-ant",
chat_model="claude-haiku-4-5",
embedding_api_key="sk-embed",
embedding_key_transport="openai",
)
)
assert env["LLM_ANTHROPIC_API_KEY"] == "sk-ant"
assert env["LLM_OPENAI_API_KEY"] == "sk-embed"
assert env["DERIVER_MODEL_CONFIG__TRANSPORT"] == "anthropic"
assert "EMBEDDING_MODEL_CONFIG__TRANSPORT" not in env
def test_openai_compatible_copies_base_url_to_embeddings():
env = answers_to_env(
SetupAnswers(
mode="basic",
provider="openai-compatible",
api_key="sk-or-test",
chat_model="gpt-test",
base_url="https://openrouter.ai/api/v1",
)
)
assert env["LLM_OPENAI_BASE_URL"] == "https://openrouter.ai/api/v1"
assert (
env["EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL"]
== "https://openrouter.ai/api/v1"
)
def test_leaving_openai_compatible_drops_proxy_urls():
dropped = answers_drop_keys(
SetupAnswers(
mode="basic", provider="openai", api_key="sk", chat_model="gpt-test"
)
)
assert "LLM_OPENAI_BASE_URL" in dropped
assert "EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL" in dropped
def test_chat_default_comes_from_image_toml(tmp_path):
path = tmp_path / "config.toml"
path.write_text(
"[deriver.model_config]\n"
'transport = "openai"\n'
'model = "gpt-from-image"\n'
)
defaults = load_toml_setup_defaults(path)
assert defaults.chat_model == "gpt-from-image"
assert chat_model_default("openai", {}, defaults) == "gpt-from-image"
assert chat_model_default("openai-compatible", {}, defaults) == "gpt-from-image"
assert chat_model_default("anthropic", {}, defaults) == ""
assert chat_model_default(
"openai",
{"DERIVER_MODEL_CONFIG__MODEL": "gpt-from-env"},
defaults,
inferred="openai",
) == "gpt-from-env"

View File

@ -0,0 +1,159 @@
"""CLI contracts for `honcho start` / `stop` / `status`."""
from __future__ import annotations
import json
import os
import pytest
from honcho_cli.local.docker import image_is_digest, image_repository
from honcho_cli.main import app
from typer.testing import CliRunner
@pytest.fixture
def cfg(tmp_path, monkeypatch):
f = tmp_path / "config.json"
monkeypatch.setattr("honcho_cli.config.CONFIG_DIR", tmp_path)
monkeypatch.setattr("honcho_cli.config.CONFIG_FILE", f)
monkeypatch.setattr("honcho_cli.commands.setup.CONFIG_FILE", f)
for k in [k for k in os.environ if k.startswith(("HONCHO_", "LLM_"))]:
monkeypatch.delenv(k)
return f
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture(autouse=True)
def _host_ports_free(monkeypatch):
monkeypatch.setattr("honcho_cli.local.docker.port_available", lambda *a, **k: True)
@pytest.fixture(autouse=True)
def _stub_image_pin(monkeypatch):
def fake_pin(image: str) -> str:
if image_is_digest(image):
return image
return f"{image_repository(image)}@sha256:cafedeadbeef"
monkeypatch.setattr("honcho_cli.commands.stack.pin_image", fake_pin)
monkeypatch.setattr("honcho_cli.commands.stack.seed_config_toml", lambda profile: False)
_PS = [
{"Service": "api", "State": "running", "Health": "healthy"},
{"Service": "deriver", "State": "running"},
{"Service": "database", "State": "running", "Health": "healthy"},
{"Service": "redis", "State": "running", "Health": "healthy"},
]
def test_start_does_not_rewrite_environment_url(cfg, runner, monkeypatch):
cfg.write_text(
json.dumps({"apiKey": "k", "environmentUrl": "https://api.honcho.dev"})
)
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False)
monkeypatch.setattr("honcho_cli.commands.stack.compose_up", lambda profile, **k: None)
monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True)
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS)
monkeypatch.setenv("LLM_OPENAI_API_KEY", "sk-test")
result = runner.invoke(app, ["start", "--json"])
assert result.exit_code == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["endpoints"]["api"] == "http://127.0.0.1:8000"
assert payload["image"].endswith("@sha256:cafedeadbeef")
on_disk = json.loads(cfg.read_text())
assert on_disk["environmentUrl"] == "https://api.honcho.dev"
def test_start_requires_llm_key(cfg, runner, monkeypatch):
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: False)
result = runner.invoke(app, ["start"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "MISSING_LLM_KEY"
def test_stop_already_stopped_skips_down(cfg, runner, tmp_path, monkeypatch):
compose = tmp_path / "profiles" / "local" / "docker-compose.yml"
compose.parent.mkdir(parents=True)
compose.write_text("services: {}\n")
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: [])
down = []
monkeypatch.setattr(
"honcho_cli.commands.stack.compose_down",
lambda profile, wipe=False: down.append(wipe),
)
result = runner.invoke(app, ["stop"])
assert result.exit_code == 0, result.stderr
assert down == []
assert json.loads(result.stdout)["status"] == "stopped"
def test_status_lists_profiles_or_one(cfg, runner, tmp_path, monkeypatch):
for name, port in (("demo", 8001), ("local", 8000)):
d = tmp_path / "profiles" / name
d.mkdir(parents=True)
(d / "docker-compose.yml").write_text("services: {}\n")
(d / "profile.json").write_text(json.dumps({"apiPort": port}) + "\n")
monkeypatch.setattr(
"honcho_cli.commands.stack.compose_ps",
lambda profile: _PS if profile.name == "local" else [],
)
monkeypatch.setattr(
"honcho_cli.commands.stack.stack_healthy",
lambda profile: profile.name == "local",
)
listed = runner.invoke(app, ["status"])
assert listed.exit_code == 0, listed.stderr
rows = json.loads(listed.stdout)["profiles"]
by_name = {row["profile"]: row for row in rows}
assert by_name["local"]["status"] == "running"
assert by_name["demo"]["endpoints"]["api"] == "http://127.0.0.1:8001"
one = runner.invoke(app, ["status", "--profile", "local"])
assert one.exit_code == 0, one.stderr
payload = json.loads(one.stdout)
assert payload["profile"] == "local"
assert "profiles" not in payload
def test_start_setup_requires_tty(cfg, runner):
result = runner.invoke(app, ["start", "--setup", "basic", "--json"])
assert result.exit_code == 1
assert json.loads(result.stderr)["error"]["code"] == "SETUP_REQUIRES_TTY"
def test_start_setup_recreates_when_already_running(cfg, runner, monkeypatch):
from honcho_cli.local.setup import SetupAnswers
ups: list[tuple[str, ...]] = []
monkeypatch.setattr("honcho_cli.commands.stack.use_json", lambda: False)
monkeypatch.setattr("honcho_cli.commands.stack.stack_healthy", lambda profile: True)
monkeypatch.setattr(
"honcho_cli.commands.stack.compose_up",
lambda profile, **k: ups.append(k.get("recreate", ())),
)
monkeypatch.setattr("honcho_cli.commands.stack.wait_for_health", lambda *a, **k: True)
monkeypatch.setattr("honcho_cli.commands.stack.compose_ps", lambda profile: _PS)
monkeypatch.setattr(
"honcho_cli.commands.stack.run_setup",
lambda mode, path, config_path=None: SetupAnswers(
mode="basic",
provider="openai",
api_key="sk-wiz",
chat_model="gpt-test",
),
)
pins: list[str] = []
monkeypatch.setattr(
"honcho_cli.commands.stack.pin_image",
lambda image: pins.append(image) or image,
)
result = runner.invoke(app, ["start", "--setup", "basic"])
assert result.exit_code == 0, result.stderr
assert pins == []
assert ups == [("api", "deriver")]

View File

@ -1,6 +1,6 @@
[project]
name = "honcho"
version = "3.0.12"
version = "3.1.0"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},

View File

@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Session.last_message_at` exposes the newest message timestamp, and sync/async `Honcho.sessions()` accept `sort_by="created_at" | "last_message_at"` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
## [2.4.0] - 2026-08-25
### Added
- Scopes: `Honcho.scope()` / `HonchoAio.scope()` get-or-create a named visibility boundary, `Honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `Honcho.session(..., scopes=[...])` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
- `scope` option on `Peer.chat()` / `chat_stream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
- Workspace-level chat: `Honcho.chat()` / `HonchoAio.chat()` and `chat_stream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoning_level`, and `response_format` options as `Peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
### Changed
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
## [2.3.0] - 2026-08-10
### Added

View File

@ -1,6 +1,6 @@
[project]
name = "honcho-ai"
version = "2.3.0"
version = "2.4.0"
description = "Official DX Optimized Python SDK for Honcho"
dynamic = ["readme"]
license = "Apache-2.0"

View File

@ -507,6 +507,79 @@ class HonchoAio(AsyncMetadataConfigMixin):
"""Delete a workspace asynchronously."""
await self._honcho._async_http_client.delete(routes.workspace(workspace_id))
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def chat(
self,
query: str = Field(..., min_length=1, description="The natural language query"),
*,
session: str | SessionBase | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
scope: str | list[str] | None = None,
) -> BaseModel | str | None:
"""Query the entire workspace asynchronously (see Honcho.chat)."""
await self._honcho._ensure_workspace_async()
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": False}
if resolved_session_id:
body["session_id"] = resolved_session_id
if reasoning_level:
body["reasoning_level"] = reasoning_level
if scope is not None:
body["scope"] = scope
response_format_schema = serialize_response_format(response_format)
if response_format_schema is not None:
body["response_format"] = response_format_schema
data = await self._honcho._async_http_client.post(
routes.workspace_chat(self._honcho.workspace_id),
body=body,
)
content = data.get("content")
if not content:
return None
if isinstance(response_format, type):
return response_format.model_validate_json(content)
return content
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def chat_stream(
self,
query: str = Field(..., min_length=1, description="The natural language query"),
*,
session: str | SessionBase | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
scope: str | list[str] | None = None,
) -> AsyncDialecticStreamResponse:
"""Streaming variant of :meth:`chat` (async)."""
await self._honcho._ensure_workspace_async()
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": True}
if resolved_session_id:
body["session_id"] = resolved_session_id
if reasoning_level:
body["reasoning_level"] = reasoning_level
if scope is not None:
body["scope"] = scope
response_format_schema = serialize_response_format(response_format)
if response_format_schema is not None:
body["response_format"] = response_format_schema
async def stream_response() -> AsyncGenerator[str, None]:
async for chunk in parse_sse_astream(
self._honcho._async_http_client.stream(
"POST",
routes.workspace_chat(self._honcho.workspace_id),
body=body,
)
):
yield chunk
return AsyncDialecticStreamResponse(stream_response())
@validate_call
async def search(
self,

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import os
from collections.abc import Mapping, Sequence
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Literal
import httpx
@ -28,10 +28,16 @@ from .http import AsyncHonchoHTTPClient, HonchoHTTPClient, routes
from .message import Message
from .mixins import MetadataConfigMixin
from .pagination import SyncPage
from .peer import Peer
from .peer import Peer, serialize_response_format
from .scope import Scope
from .session import Session
from .utils import normalize_peers_to_dict, resolve_id, validate_scope_id
from .types import DialecticStreamResponse
from .utils import (
normalize_peers_to_dict,
parse_sse_stream,
resolve_id,
validate_scope_id,
)
logger = logging.getLogger(__name__)
@ -694,6 +700,98 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
"""
self._http.delete(routes.workspace(workspace_id))
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def chat(
self,
query: str = Field(..., min_length=1, description="The natural language query"),
*,
session: str | SessionBase | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
scope: str | list[str] | None = None,
) -> BaseModel | str | None:
"""
Query the entire workspace with a natural language question.
Unlike peer.chat(), which queries a single peer's representation, this
searches across ALL peers and observations in the workspace use it
for cross-peer analysis, common themes, or workspace-wide questions.
Args:
query: The natural language question to ask.
session: Optional session to scope message retrieval to.
reasoning_level: Optional reasoning level: "minimal", "low",
"medium", "high", or "max" (default "low").
response_format: Optional structure for the answer: a Pydantic
model class (returns a parsed instance) or a raw
JSON Schema dict (returns a JSON string).
scope: Optional scope name(s) restricting recall to those scopes'
member sessions. Mutually exclusive with `session`.
Returns:
The synthesized answer, or None if no relevant information.
"""
self._ensure_workspace()
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": False}
if resolved_session_id:
body["session_id"] = resolved_session_id
if reasoning_level:
body["reasoning_level"] = reasoning_level
if scope is not None:
body["scope"] = scope
response_format_schema = serialize_response_format(response_format)
if response_format_schema is not None:
body["response_format"] = response_format_schema
data = self._http.post(
routes.workspace_chat(self.workspace_id),
body=body,
)
content = data.get("content")
if not content:
return None
if isinstance(response_format, type):
return response_format.model_validate_json(content)
return content
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def chat_stream(
self,
query: str = Field(..., min_length=1, description="The natural language query"),
*,
session: str | SessionBase | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
scope: str | list[str] | None = None,
) -> DialecticStreamResponse:
"""Streaming variant of :meth:`chat`. See chat() for argument docs."""
self._ensure_workspace()
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": True}
if resolved_session_id:
body["session_id"] = resolved_session_id
if reasoning_level:
body["reasoning_level"] = reasoning_level
if scope is not None:
body["scope"] = scope
response_format_schema = serialize_response_format(response_format)
if response_format_schema is not None:
body["response_format"] = response_format_schema
def stream_response() -> Generator[str, None, None]:
yield from parse_sse_stream(
self._http.stream(
"POST",
routes.workspace_chat(self.workspace_id),
body=body,
)
)
return DialecticStreamResponse(stream_response())
@validate_call
def search(
self,

View File

@ -16,6 +16,10 @@ def workspace(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}"
def workspace_chat(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/chat"
def workspace_search(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/search"

View File

@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Session.lastMessageAt` exposes the newest message timestamp, and `Honcho.sessions()` accepts `sortBy: 'created_at' | 'last_message_at'` while preserving `reverse` across pagination. Requires a Honcho server with the matching API support.
## [2.4.0] - 2026-08-25
### Added
- Scopes: `honcho.scope()` get-or-creates a named visibility boundary, `honcho.scopes()` lists them, and a `Scope` object adds/removes sessions, lists membership, and reads backfill `status()`. `honcho.session({ scopes: [...] })` joins a new session to scopes at creation. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
- `scope` option on `peer.chat()` / `chatStream()`, representation, session context, and workspace search. A single scope answers from that scope's collection and card; a list of scopes restricts recall to the union of their member sessions (explicit-only). Mutually exclusive with `session` / `sessions` / `filters`.
- Workspace-level chat: `honcho.chat()` / `honcho.chatStream()` ask a question across every peer in the workspace, with the same `session`, `scope`, `reasoningLevel`, and `responseFormat` options as `peer.chat()`. Requires a Honcho server with the matching API support (Honcho v3.1.0+).
### Changed
- `ConclusionScope` is renamed to `ConclusionsView`. The old name remains as a deprecated alias for one more minor version. "Scope" now means a named set of sessions (`Scope`); these objects are views over one observer/observed pair.
## [2.3.0] - 2026-08-10
### Added

View File

@ -1,6 +1,6 @@
{
"name": "@honcho-ai/sdk",
"version": "2.3.0",
"version": "2.4.0",
"description": "Official DX Optimized TypeScript SDK for Honcho",
"author": "Plastic Labs <hello@plasticlabs.ai>",
"license": "Apache-2.0",

View File

@ -1,5 +1,9 @@
import { API_VERSION } from './api-version'
import { HonchoHTTPClient } from './http/client'
import {
createDialecticStream,
type DialecticStreamResponse,
} from './http/streaming'
import { Message } from './message'
import { Page } from './pagination'
import { Peer } from './peer'
@ -14,6 +18,8 @@ import type {
QueueStatusResponse,
ScopeResponse,
SessionResponse,
WorkspaceChatParams,
WorkspaceChatResponse,
WorkspaceResponse,
} from './types/api'
import { resolveId, transformQueueStatus } from './utils'
@ -53,6 +59,7 @@ import {
} from './validation'
const DEFAULT_BASE_URL = 'https://api.honcho.dev'
type ReasoningLevel = 'minimal' | 'low' | 'medium' | 'high' | 'max'
/**
* Main client for the Honcho TypeScript SDK.
@ -406,6 +413,34 @@ export class Honcho {
)
}
private async _workspaceChat(
workspaceId: string,
params: WorkspaceChatParams
): Promise<WorkspaceChatResponse> {
await this._ensureWorkspace()
return this._http.post<WorkspaceChatResponse>(
`/${API_VERSION}/workspaces/${workspaceId}/chat`,
{ body: params }
)
}
private async _workspaceChatStream(
workspaceId: string,
params: Omit<WorkspaceChatParams, 'stream'>
): Promise<Response> {
await this._ensureWorkspace()
return this._http.stream(
'POST',
`/${API_VERSION}/workspaces/${workspaceId}/chat`,
{
body: {
...params,
stream: true,
},
}
)
}
// ===========================================================================
// Public Methods
// ===========================================================================
@ -960,6 +995,106 @@ export class Honcho {
return response.map(Message.fromApiResponse)
}
/**
* Query the workspace's collective knowledge using natural language.
*
* Performs agentic search and reasoning across ALL peers and observations
* in the workspace to synthesize a comprehensive answer. Useful for
* cross-peer analysis, discovering common themes, and workspace-wide queries.
*
* @param query - The natural language question to ask
* @param options.session - Optional session to scope message search to. Can be a session
* ID string or a Session object.
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low",
* "medium", "high", or "max". Defaults to "low" if not provided.
* @param options.responseFormat - Optional JSON Schema (root type "object") the response
* must conform to. When provided, the response content is a
* JSON string matching this schema.
* @returns Promise resolving to the response string, or null if no relevant information
*
* @example
* ```typescript
* const response = await honcho.chat('What are common themes across all users?')
* ```
*/
async chat(
query: string,
options?: {
session?: string | Session
reasoningLevel?: ReasoningLevel
responseFormat?: Record<string, unknown>
scope?: string | string[]
}
): Promise<string | null> {
const validatedQuery = SearchQuerySchema.parse(query)
const resolvedSessionId = options?.session
? resolveId(options.session)
: undefined
const response = await this._workspaceChat(this.workspaceId, {
query: validatedQuery,
stream: false,
session_id: resolvedSessionId,
reasoning_level: options?.reasoningLevel,
response_format: options?.responseFormat,
scope: options?.scope,
})
if (!response.content) {
return null
}
return response.content
}
/**
* Query the workspace's collective knowledge with streaming response.
*
* Performs agentic search and reasoning across ALL peers and observations
* in the workspace to synthesize a comprehensive answer, streaming the
* response as it is generated.
*
* @param query - The natural language question to ask
* @param options.session - Optional session to scope message search to. Can be a session
* ID string or a Session object.
* @param options.reasoningLevel - Optional reasoning level for the query: "minimal", "low",
* "medium", "high", or "max". Defaults to "low" if not provided.
* @param options.responseFormat - Optional JSON Schema (root type "object") the response
* must conform to. When provided, the response content is a
* JSON string matching this schema.
* @returns Promise resolving to a DialecticStreamResponse that can be iterated over
*
* @example
* ```typescript
* const stream = await honcho.chatStream('What do all peers have in common?')
* for await (const chunk of stream) {
* process.stdout.write(chunk)
* }
* ```
*/
async chatStream(
query: string,
options?: {
session?: string | Session
reasoningLevel?: ReasoningLevel
responseFormat?: Record<string, unknown>
scope?: string | string[]
}
): Promise<DialecticStreamResponse> {
const validatedQuery = SearchQuerySchema.parse(query)
const resolvedSessionId = options?.session
? resolveId(options.session)
: undefined
const response = await this._workspaceChatStream(this.workspaceId, {
query: validatedQuery,
session_id: resolvedSessionId,
reasoning_level: options?.reasoningLevel,
response_format: options?.responseFormat,
scope: options?.scope,
})
return createDialecticStream(response)
}
/**
* Get the queue processing status, optionally scoped to an observer, sender, and/or session.
*

View File

@ -69,6 +69,7 @@ export type {
SessionResponse,
SessionSummariesResponse,
SummaryResponse,
WorkspaceChatResponse,
WorkspaceResponse,
} from './types/api'

View File

@ -81,6 +81,19 @@ export interface PeerChatResponse {
content: string | null
}
export interface WorkspaceChatParams {
query: string
stream?: boolean
session_id?: string
reasoning_level?: 'minimal' | 'low' | 'medium' | 'high' | 'max'
response_format?: Record<string, unknown>
scope?: string | string[]
}
export interface WorkspaceChatResponse {
content: string | null
}
export interface PeerRepresentationParams {
session_id?: string
target?: string

View File

@ -18,6 +18,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
## Command groups
- `honcho config` — CLI configuration
- `honcho start` / `stop` / `status` — local Docker stack (does not change `environmentUrl`). First start pins the Honcho image digest and writes `config.toml` into the profile. Pass `--setup basic` or `--setup advanced` for an interactive config wizard (TTY only; writes `.env` overrides). `honcho status` lists every profile; pass `--profile` for one.
- `honcho workspace` — inspect, delete, search
- `honcho peer` — inspect, card, chat, search
- `honcho session` — inspect, view (transcript), context, summaries
@ -31,6 +32,7 @@ allowed-tools: Bash(honcho:*), Bash(jq:*), Read, Grep
- Use `honcho session context` to see exactly what an agent receives.
- Never run `honcho workspace delete` without `honcho workspace inspect` first.
- Compare peer card with conclusions to understand memory state.
- `honcho start` does not rewrite `environmentUrl`. Use `HONCHO_BASE_URL=http://127.0.0.1:8000` to talk to local stack.
## Inspection tour

24
src/cache/client.py vendored
View File

@ -123,6 +123,28 @@ def get_cache_namespace() -> str:
return cast(str, settings.CACHE.NAMESPACE)
# On Redis Cluster a key's slot is derived from the substring inside the first
# {...}, when one is present. Tagging the namespace puts every key an instance
# writes on a single slot, and therefore a single shard, so its client holds
# connections to one node rather than to all of them. Namespaces still hash
# independently of one another, so keys stay spread across the cluster.
#
# Two spellings, because the two ways a key gets built treat the string
# differently: cashews runs `prefix=` through format substitution, so braces
# have to be doubled to survive as literals, while direct construction does no
# substitution and needs them single. Both render to the same bytes, which
# tests/cache/test_cache_namespace_hash_tag.py asserts -- a mismatch would send
# writes and deletes to different keys with nothing raised.
def cache_key_namespace() -> str:
"""Tagged namespace for keys built by string concatenation."""
return "{" + get_cache_namespace() + "}"
def cache_prefix_namespace() -> str:
"""Tagged namespace for cashews `prefix=`, which format-substitutes."""
return "{{" + get_cache_namespace() + "}}"
async def init_cache() -> None:
"""Initialize and verify cache connection if enabled."""
async with _cache_lock:
@ -256,6 +278,8 @@ __all__ = [
"init_cache",
"close_cache",
"cache",
"cache_key_namespace",
"cache_prefix_namespace",
"safe_cache_delete",
"safe_cache_set",
]

View File

@ -58,6 +58,7 @@ from .scope import (
invalidate_scope_peer_cache,
remove_session_from_scope,
resolve_scope_peers,
resolve_scope_session_union,
update_scope_backfill_status,
)
from .session import (
@ -81,16 +82,24 @@ from .webhook import (
list_webhook_endpoints,
)
from .workspace import (
ActivePeer,
WorkspaceDeletionResult,
WorkspaceStats,
check_no_active_sessions,
delete_workspace,
get_active_peers,
get_all_workspaces,
get_or_create_workspace,
get_workspace,
get_workspace_stats,
update_workspace,
)
__all__ = [
"get_workspace_stats",
"get_active_peers",
"WorkspaceStats",
"ActivePeer",
# Collection
"get_collection",
"get_or_create_collection",
@ -150,6 +159,7 @@ __all__ = [
"invalidate_scope_peer_cache",
"remove_session_from_scope",
"resolve_scope_peers",
"resolve_scope_session_union",
"update_scope_backfill_status",
# Session
"SessionDeletionResult",

View File

@ -10,7 +10,8 @@ from sqlalchemy.orm import make_transient_to_detached
from src import models
from src.cache.client import (
cache,
get_cache_namespace,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
safe_cache_set,
)
@ -22,13 +23,13 @@ logger = getLogger(__name__)
COLLECTION_CACHE_KEY_TEMPLATE = (
"v2:workspace:{workspace_name}:collection:{observer}:{observed}"
)
COLLECTION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
COLLECTION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def collection_cache_key(workspace_name: str, observer: str, observed: str) -> str:
"""Generate cache key for collection."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ COLLECTION_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
@ -41,7 +42,7 @@ def collection_cache_key(workspace_name: str, observer: str, observed: str) -> s
@cache(
key=COLLECTION_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(

View File

@ -18,7 +18,7 @@ from src.crud.collection import get_or_create_collection
from src.crud.peer import get_peer, reject_scope_observed
from src.crud.session import get_session
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.exceptions import (
ResourceNotFoundException,
ValidationException,
@ -231,6 +231,9 @@ async def query_external_vector_document_ids(
if _uses_pgvector():
return None
if top_k <= 0:
return []
external_vector_store = get_external_vector_store()
if external_vector_store is None:
return []
@ -362,7 +365,7 @@ async def query_documents(
if embedding is None:
try:
embedding = await embedding_client.embed(query)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise ValidationException(
"Query exceeds maximum token limit of "
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."
@ -987,7 +990,7 @@ async def create_observations(
embeddings = await embedding_client.simple_batch_embed(
contents, on_oversize="truncate"
)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise ValidationException(str(e)) from e
# Create document objects and track embeddings for vector store

View File

@ -16,6 +16,7 @@ from sqlalchemy import (
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from src import models, schemas
from src.cache.client import safe_cache_delete
@ -170,6 +171,91 @@ async def resolve_session_scope(
return (allowed, False) if allowed else (None, True)
def observer_scope_clause(
workspace_name: str,
observer: str,
session_column: InstrumentedAttribute[str],
) -> ColumnElement[bool]:
"""Correlated EXISTS restricting ``session_column`` to the observer's sessions.
The in-database equivalent of filtering on :func:`get_peer_session_names`.
Prefer it whenever the scope feeds a single SQL statement: a peer's
membership count is unbounded, and materializing the names turns each one
into its own bind parameter. The PostgreSQL wire protocol caps parameters
at 65535 per statement, so a peer in enough sessions produces a query the
driver cannot serialize at all and the resulting error carries every
parameter in its text.
Matches the loose membership definition ``get_peer_session_names`` uses by
default: any membership record grants visibility, whether or not the peer
has since left the session.
"""
session_peers = models.session_peers_table
return (
select(1)
.where(session_peers.c.workspace_name == workspace_name)
.where(session_peers.c.peer_name == observer)
.where(session_peers.c.session_name == session_column)
.exists()
)
def resolve_session_scope_clauses(
workspace_name: str,
session_name: str | None,
session_allowlist: list[str] | None,
observer: str | None,
session_column: InstrumentedAttribute[str],
) -> tuple[list[ColumnElement[bool]], bool]:
"""SQL-side counterpart to :func:`resolve_session_scope`.
Returns ``(clauses, deny)``, where ``clauses`` are ANDed onto the caller's
statement and ``deny=True`` means return an empty result without querying.
Unlike :func:`resolve_session_scope` this touches no database and grows no
bind parameters with the observer's session count — the observer half
becomes a correlated EXISTS instead of an ``IN`` over fetched names.
Scoping matches :func:`resolve_session_scope` case for case, with one
deliberate difference: where that function returns ``deny=True`` because an
observer's membership (or its intersection with the allowlist) is empty,
this returns clauses that simply match no rows. Callers reach the same empty
result, at the cost of running one indexed query that returns nothing.
``session_allowlist`` stays an ``IN`` clause: it is caller-supplied and
therefore bounded, so it carries none of the unbounded-growth risk.
Args:
workspace_name: Name of the workspace
session_name: A single pinned session, if the caller named one. The
caller applies its own equality filter; this function only checks
the allowlist permits it.
session_allowlist: Optional session allowlist. ``None`` is
unrestricted; an empty list fails closed.
observer: When set, scope is limited to this peer's sessions
session_column: The session-name column to scope, e.g.
``models.Message.session_name``
"""
if session_name:
# Fail closed when the allowlist forbids the pinned session, matching
# `resolve_session_scope` — routes guard this too, but the dialectic
# tools reach CRUD directly, so enforce it at the boundary.
if session_allowlist is not None and session_name not in session_allowlist:
return [], True
return [], False
clauses: list[ColumnElement[bool]] = []
if observer is not None:
clauses.append(observer_scope_clause(workspace_name, observer, session_column))
if session_allowlist is not None:
if not session_allowlist:
return [], True
clauses.append(session_column.in_(session_allowlist))
return clauses, False
def _apply_token_limit(
base_conditions: list[ColumnElement[Any]], token_limit: int
) -> Select[tuple[models.Message]]:
@ -695,6 +781,9 @@ async def _search_messages_external(
Multiple vector records can map to the same message (chunked embeddings),
so we oversample from the vector store and deduplicate by message_id.
"""
if limit <= 0:
return []
external_vector_store = get_external_vector_store()
if external_vector_store is None:
return []

View File

@ -12,7 +12,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import make_transient_to_detached
from src import models, schemas
from src.cache.client import cache, get_cache_namespace, safe_cache_delete
from src.cache.client import (
cache,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
)
from src.config import settings
from src.crud.workspace import get_or_create_workspace
from src.exceptions import (
@ -32,13 +37,13 @@ logger = getLogger(__name__)
PEER_NAME_MAX_LENGTH = 512
PEER_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}:peer:{peer_name}"
PEER_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
PEER_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def peer_cache_key(workspace_name: str, peer_name: str) -> str:
"""Generate cache key for peer."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ PEER_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
@ -391,7 +396,7 @@ async def get_or_create_peers(
@cache(
key=PEER_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(

View File

@ -13,7 +13,7 @@ from src import crud, exceptions, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.dreamer.dream_scheduler import check_and_schedule_dream
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.schemas import ResolvedConfiguration
from src.telemetry.events import EmbeddingCallPurpose
from src.telemetry.logging import accumulate_metric
@ -109,7 +109,7 @@ class RepresentationManager:
embeddings = await embedding_client.simple_batch_embed(
observation_texts, on_oversize="truncate"
)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise exceptions.ValidationException(
"Observation content exceeds maximum token limit of "
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}."

View File

@ -319,6 +319,26 @@ async def resolve_scope_peers(
return resolved
async def resolve_scope_session_union(
db: AsyncSession,
workspace_name: str,
scope_names: Sequence[str],
) -> list[str]:
"""Return the union of member sessions across the given scopes."""
from src.crud.message import get_peer_session_names
union: list[str] = []
seen: set[str] = set()
for scope_peer in await resolve_scope_peers(db, workspace_name, scope_names):
for session_name in await get_peer_session_names(
db, workspace_name, scope_peer
):
if session_name not in seen:
seen.add(session_name)
union.append(session_name)
return union
async def get_scope_sessions(
workspace_name: str,
scope_name: str,

View File

@ -29,7 +29,8 @@ from sqlalchemy.types import BigInteger, Boolean
from src import models, schemas
from src.cache.client import (
cache,
get_cache_namespace,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
safe_cache_set,
)
@ -67,13 +68,13 @@ class SessionDeletionResult:
SESSION_CACHE_KEY_TEMPLATE = "v3:workspace:{workspace_name}:session:{session_name}"
SESSION_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v3"
SESSION_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v3"
def session_cache_key(workspace_name: str, session_name: str) -> str:
"""Generate cache key for session."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ SESSION_CACHE_KEY_TEMPLATE.format(
workspace_name=workspace_name,
@ -85,7 +86,7 @@ def session_cache_key(workspace_name: str, session_name: str) -> str:
@cache(
key=SESSION_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(
@ -1069,25 +1070,31 @@ async def set_peers_for_session(
peer_names: dict[str, schemas.SessionPeerConfig],
) -> list[models.SessionPeer]:
"""
Set peers for a session, overwriting any existing peers.
If peers don't exist, they will be created.
Replace a session's ordinary peer set with ``peer_names``.
Active members keep their joined_at but take the incoming configuration:
this is a replace, so the caller's map is the desired end state. Departed
members rejoin with the incoming configuration. Scope memberships are
preserved.
Args:
db: Database session
workspace_name: Name of the workspace
session_name: Name of the session
peer_names: Set of peer names to set for the session
peer_names: Mapping of peer names to session-level configuration
Returns:
List of SessionPeer objects for all peers in the session
Raises:
ResourceNotFoundException: If the session does not exist
ObserverException: If the resulting peer set would exceed the observer
limit
"""
# Validate observer limit before making any changes
observer_count = count_observers_in_config(peer_names)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
# No observer pre-check here: an already-active membership keeps its stored
# configuration, so the incoming map is not what lands. Counting it would
# reject a request that lowers the observer count as often as one that raises
# it. _get_or_add_peers_to_session enforces the limit on the resulting rows.
# Verify session exists
stmt = (
@ -1103,20 +1110,21 @@ async def set_peers_for_session(
f"Session {session_name} not found in workspace {workspace_name}"
)
# Soft delete every *ordinary* active membership. Scope memberships are
# deliberately preserved: this route replaces the peers the caller names, and a
# caller detaches a scope by simply *omitting* it from an otherwise valid
# replacement map — never naming it, so no request-level guard can see it.
# Without the exclusion a plain replacement silently bypasses the facade that
# owns scope membership and its removal reconciliation. Being part of the
# UPDATE, this holds regardless of the request body or concurrent scope
# creation.
# Soft delete every *ordinary* active membership not in the incoming map.
# Scope memberships are deliberately preserved: this route replaces the peers
# the caller names, and a caller detaches a scope by simply *omitting* it from
# an otherwise valid replacement map — never naming it, so no request-level
# guard can see it. Without the exclusion a plain replacement silently
# bypasses the facade that owns scope membership and its removal
# reconciliation. Being part of the UPDATE, this holds regardless of the
# request body or concurrent scope creation.
update_stmt = (
update(models.SessionPeer)
.where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only update active peers
models.SessionPeer.peer_name.notin_(peer_names.keys()),
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
@ -1137,12 +1145,14 @@ async def set_peers_for_session(
)
_reject_resolved_scope_peers(peers_result.resource)
# Add new peers to session
# Add new peers to session. This route replaces the session's peer set, so the
# incoming configuration is authoritative even for an already-active member.
peers = await _get_or_add_peers_to_session(
db,
workspace_name=workspace_name,
session_name=session_name,
peer_names=peer_names,
replace_config=True,
)
await db.commit()
@ -1181,13 +1191,22 @@ async def _get_or_add_peers_to_session(
peer_names: dict[str, schemas.SessionPeerConfig],
*,
fetch_after_upsert: bool = True,
replace_config: bool = False,
) -> list[models.SessionPeer]:
"""
Upsert session-peer memberships for a session and optionally fetch the
active memberships afterward.
New peers are inserted, peers that previously left the session are rejoined,
and already-active peers keep their existing session-level configuration.
and already-active peers keep their existing joined_at.
An already-active peer also keeps its stored configuration unless
``replace_config`` is set: an add must not overwrite configuration it was
never asked about, while a replace states the desired end state.
The observer limit is checked against the rows the upsert actually produced,
not against the incoming map, since under the add semantics the incoming map
is not necessarily what lands.
Args:
db: Database session
@ -1196,13 +1215,17 @@ async def _get_or_add_peers_to_session(
peer_names: Mapping of peer names to session-level configuration
fetch_after_upsert: If True, query and return the active session peers
after the upsert. If False, skip that read and return an empty list.
replace_config: If True, an already-active membership takes the incoming
configuration instead of keeping its stored one. Set by replace-style
callers; leave False for add-style callers.
Returns:
Active SessionPeer objects after the upsert, or an empty list when the
post-upsert fetch is skipped
Raises:
ObserverException: If adding peers would exceed the observer limit
ObserverException: If the resulting active peer set would exceed the
observer limit
"""
# If no peers to add, skip the insert and just return existing active session peers
if not peer_names:
@ -1221,43 +1244,10 @@ async def _get_or_add_peers_to_session(
# costs document rows, not LLM calls, and counting them would
# cap scopes-per-session at SESSION_OBSERVERS_LIMIT and surface as an
# observer-shaped 400 through a facade that hides observers entirely.
# Resolved up front because the limit check below gates on whether this
# request asks for a *non-scope* observer.
scopes_being_added = await scope_peer_names(db, workspace_name, peer_names.keys())
# Only validate observer limit if we're adding non-scope peers with observe_others=True
new_observer_count = count_observers_in_config(
{n: c for n, c in peer_names.items() if n not in scopes_being_added}
)
if new_observer_count > 0:
# Use a single efficient query to count existing observers not being updated
# This uses PostgreSQL's JSONB operators to check the observe_others field directly
existing_observers_stmt = select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.peer_name.notin_(
peer_names.keys()
), # Exclude peers being updated
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
# Existing scope memberships are excluded for the same reason as above.
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == models.SessionPeer.peer_name)
.where(scope_peer_clause())
.correlate(models.SessionPeer)
),
)
result = await db.execute(existing_observers_stmt)
existing_observer_count = result.scalar() or 0
total_observers = existing_observer_count + new_observer_count
if total_observers > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, total_observers)
# Use upsert to handle both new peers and rejoining peers
stmt = pg_insert(models.SessionPeer).values(
[
@ -1273,15 +1263,27 @@ async def _get_or_add_peers_to_session(
]
)
# On conflict, update joined_at and clear left_at (rejoin scenario)
# If left_at is not None (peer has left the session): Use the new configuration (stmt.excluded.configuration)
# If left_at is None (peer is still active): Keep the existing configuration (models.SessionPeer.configuration)
# On conflict, rejoin departed peers. joined_at always survives on an active
# membership -- advancing it would move the peer_perspective search window
# past messages the peer was present for (issue #940).
#
# Configuration depends on the caller's semantics. An add ("ensure this peer
# is here") must not silently overwrite a config it never asked about, so an
# active membership keeps its stored one. A replace ("these are the session's
# peers, configured thus") states a desired end state, so the incoming config
# wins -- otherwise PUT /peers could never change the configuration of a peer
# already in the session.
stmt = stmt.on_conflict_do_update(
index_elements=["session_name", "peer_name", "workspace_name"],
set_={
"joined_at": func.now(),
"joined_at": case(
(models.SessionPeer.left_at.is_not(None), func.now()),
else_=models.SessionPeer.joined_at,
),
"left_at": None,
"configuration": case(
"configuration": stmt.excluded.configuration
if replace_config
else case(
(models.SessionPeer.left_at.is_not(None), stmt.excluded.configuration),
else_=models.SessionPeer.configuration,
),
@ -1289,6 +1291,49 @@ async def _get_or_add_peers_to_session(
)
await db.execute(stmt)
# Enforce the observer limit on the resulting rows rather than predicting them.
# Under add semantics an already-active membership keeps its stored
# configuration (see the CASE above), so the incoming config is not what lands
# and cannot be counted: predicting from it silently undercounts preserved
# observers and lets a session grow past the limit indefinitely by re-sending
# its current observers at a lower config alongside new ones. Counting after
# the upsert is correct under both configuration semantics and cannot desync
# from those branches. Raising here rolls the upsert back: ObserverException
# is never caught, and both get_db and tracked_db roll back on exception.
#
# Gated on the request actually asking for a non-scope observer so that a
# session already over the limit keeps behaving as it does today: it can
# still take non-observers and scope attachments, and only a request that
# would make it worse is rejected.
if any(
config.observe_others
for peer_name, config in peer_names.items()
if peer_name not in scopes_being_added
):
observer_count = (
await db.scalar(
select(func.count()).where(
models.SessionPeer.session_name == session_name,
models.SessionPeer.workspace_name == workspace_name,
models.SessionPeer.left_at.is_(None), # Only active peers
models.SessionPeer.configuration["observe_others"].astext.cast(
Boolean
), # Only observers
# Scope memberships are excluded for the reason given above.
~exists(
select(models.Peer.id)
.where(models.Peer.workspace_name == workspace_name)
.where(models.Peer.name == models.SessionPeer.peer_name)
.where(scope_peer_clause())
.correlate(models.SessionPeer)
),
)
)
or 0
)
if observer_count > settings.SESSION_OBSERVERS_LIMIT:
raise ObserverException(session_name, observer_count)
if not fetch_after_upsert:
return []

View File

@ -1,6 +1,8 @@
"""CRUD helpers for workspace records and workspace deletion checks."""
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from logging import getLogger
from typing import Any
@ -13,7 +15,8 @@ from sqlalchemy.orm import make_transient_to_detached
from src import models, schemas
from src.cache.client import (
cache,
get_cache_namespace,
cache_key_namespace,
cache_prefix_namespace,
safe_cache_delete,
safe_cache_set,
)
@ -38,13 +41,13 @@ class WorkspaceDeletionResult:
WORKSPACE_CACHE_KEY_TEMPLATE = "v2:workspace:{workspace_name}"
WORKSPACE_LOCK_PREFIX = f"{get_cache_namespace()}:lock:v2"
WORKSPACE_LOCK_PREFIX = f"{cache_prefix_namespace()}:lock:v2"
def workspace_cache_key(workspace_name: str) -> str:
"""Generate cache key for workspace."""
return (
get_cache_namespace()
cache_key_namespace()
+ ":"
+ WORKSPACE_CACHE_KEY_TEMPLATE.format(workspace_name=workspace_name)
)
@ -53,7 +56,7 @@ def workspace_cache_key(workspace_name: str) -> str:
@cache(
key=WORKSPACE_CACHE_KEY_TEMPLATE,
ttl=f"{settings.CACHE.DEFAULT_TTL_SECONDS}s",
prefix=get_cache_namespace(),
prefix=cache_prefix_namespace(),
condition=NOT_NONE,
)
@cache.locked(
@ -535,3 +538,206 @@ async def delete_workspace(
messages_deleted=messages_count,
conclusions_deleted=conclusions_count,
)
@dataclass
class WorkspaceStats:
"""Workspace-level aggregate statistics."""
peer_count: int
session_count: int
message_count: int
oldest_message_at: datetime | None
newest_message_at: datetime | None
@dataclass
class ActivePeer:
"""A peer with activity metrics."""
name: str
message_count: int
last_message_at: datetime | None
async def get_workspace_stats(
db: AsyncSession,
workspace_name: str,
session_names: Sequence[str] | None = None,
) -> WorkspaceStats:
"""Get aggregate statistics for a workspace.
Scope peers are excluded from ``peer_count``. When ``session_names`` is
provided, counts are restricted to that allowlist (empty zeros).
"""
from src.crud.peer import scope_peer_clause
if session_names is not None and not session_names:
return WorkspaceStats(
peer_count=0,
session_count=0,
message_count=0,
oldest_message_at=None,
newest_message_at=None,
)
msg_filters = [models.Message.workspace_name == workspace_name]
if session_names is not None:
msg_filters.append(models.Message.session_name.in_(session_names))
peer_count = int(
await db.scalar(
select(func.count(func.distinct(models.Message.peer_name)))
.select_from(models.Message)
.join(
models.Peer,
(models.Peer.workspace_name == models.Message.workspace_name)
& (models.Peer.name == models.Message.peer_name),
)
.where(*msg_filters, ~scope_peer_clause())
)
or 0
)
session_count = int(
await db.scalar(
select(func.count(models.Session.id)).where(
models.Session.workspace_name == workspace_name,
models.Session.name.in_(session_names),
)
)
or 0
)
else:
peer_count = int(
await db.scalar(
select(func.count(models.Peer.id)).where(
models.Peer.workspace_name == workspace_name,
~scope_peer_clause(),
)
)
or 0
)
session_count = int(
await db.scalar(
select(func.count(models.Session.id)).where(
models.Session.workspace_name == workspace_name
)
)
or 0
)
msg_row = (
await db.execute(
select(
func.count(models.Message.id),
func.min(models.Message.created_at),
func.max(models.Message.created_at),
).where(*msg_filters)
)
).one()
message_count = int(msg_row[0] or 0)
oldest_message_at = msg_row[1]
newest_message_at = msg_row[2]
return WorkspaceStats(
peer_count=peer_count,
session_count=session_count,
message_count=message_count,
oldest_message_at=oldest_message_at,
newest_message_at=newest_message_at,
)
# Activity window for get_active_peers. Bounds the per-peer aggregation
# (which runs on the workspace-chat request path) so it never scans a large
# workspace's full message history; peers idle longer than this still appear
# via the Peer outer join, with zero count and no last-active date.
ACTIVE_PEER_WINDOW_DAYS = 90
async def get_active_peers(
db: AsyncSession,
workspace_name: str,
limit: int = 20,
sort_by: str = "recent_activity",
session_names: Sequence[str] | None = None,
) -> list[ActivePeer]:
"""Get the most active peers in a workspace.
Activity is measured over the trailing ACTIVE_PEER_WINDOW_DAYS days.
Scope peers are excluded. When ``session_names`` is provided, only peers
with messages in that allowlist are returned (empty no peers).
"""
from src.crud.peer import scope_peer_clause
if limit <= 0:
return []
if session_names is not None and not session_names:
return []
limit = min(limit, 50)
window_start = datetime.now(timezone.utc) - timedelta(days=ACTIVE_PEER_WINDOW_DAYS)
msg_filters = [
models.Message.workspace_name == workspace_name,
models.Message.created_at >= window_start,
]
if session_names is not None:
msg_filters.append(models.Message.session_name.in_(session_names))
# Subquery: aggregate messages per peer within the activity window
subq = (
select(
models.Message.peer_name,
func.count(models.Message.id).label("msg_count"),
func.max(models.Message.created_at).label("last_msg_at"),
)
.where(*msg_filters)
.group_by(models.Message.peer_name)
.subquery()
)
columns = (
models.Peer.name,
func.coalesce(subq.c.msg_count, 0).label("msg_count"),
subq.c.last_msg_at,
)
if session_names is not None:
stmt = (
select(*columns)
.join(subq, models.Peer.name == subq.c.peer_name)
.where(
models.Peer.workspace_name == workspace_name,
~scope_peer_clause(),
)
)
else:
stmt = (
select(*columns)
.outerjoin(subq, models.Peer.name == subq.c.peer_name)
.where(
models.Peer.workspace_name == workspace_name,
~scope_peer_clause(),
)
)
# Peer name as secondary key so ties (notably all-NULL activity in young
# workspaces) return a stable order across calls.
if sort_by == "message_count":
stmt = stmt.order_by(
func.coalesce(subq.c.msg_count, 0).desc(), models.Peer.name
)
else:
# Default: recent_activity — peers with most recent messages first
stmt = stmt.order_by(subq.c.last_msg_at.desc().nulls_last(), models.Peer.name)
stmt = stmt.limit(limit)
rows = (await db.execute(stmt)).all()
return [
ActivePeer(
name=row[0],
message_count=int(row[1]),
last_message_at=row[2],
)
for row in rows
]

View File

@ -12,7 +12,11 @@ from sqlalchemy.orm import declarative_base
from sqlalchemy.pool import NullPool, QueuePool
from src.config import settings
from src.telemetry.prometheus.metrics import db_queries_in_flight_gauge
from src.telemetry.prometheus.metrics import (
db_connections_established_counter,
db_connections_open_gauge,
db_queries_in_flight_gauge,
)
logger = logging.getLogger(__name__)
@ -220,6 +224,81 @@ def register_db_query_instrumentation(instance_type: str) -> None:
_db_query_instrumentation_registered = True
class DBConnectionTracker:
"""Tracks physical DB connections open on this engine via pool lifecycle events.
Drift-proof, mirroring ``DBQueryInflightTracker``: marks the ``ConnectionRecord``
on ``connect`` and decrements only if that mark is still present on
``close``/``invalidate``, so each physical connection increments the gauge
exactly once and decrements at most once it can't leak upward or go negative
when both events fire during invalidation cleanup. Works for every pool class,
including ``NullPool`` (whose pool keeps no records, so the scrape-time
``db_pool_connections`` collector reads zero).
"""
# Marker on ConnectionRecord.info recording that we incremented for this
# connection, so we decrement exactly once across close/invalidate.
OPEN_KEY: str = "_honcho_conn_open"
def __init__(self, open_child: Any, established_child: Any) -> None:
self._open: Any = open_child
self._established: Any = established_child
def on_connect(self, _dbapi_connection: Any, connection_record: Any) -> None:
try:
connection_record.info[self.OPEN_KEY] = True
self._established.inc()
self._open.inc()
except Exception:
logger.debug("db-connection gauge inc failed", exc_info=True)
def on_close(self, _dbapi_connection: Any, connection_record: Any, *_: Any) -> None:
try:
if connection_record is not None and connection_record.info.pop(
self.OPEN_KEY, False
):
self._open.dec()
except Exception:
logger.debug("db-connection gauge dec failed", exc_info=True)
# Process-wide tracker, created at registration (None until then / if metrics off).
_connection_tracker: DBConnectionTracker | None = None
_db_connection_instrumentation_registered = False
def register_db_connection_instrumentation(instance_type: str) -> None:
"""Attach physical-connection tracking to the engine (no-op if metrics off).
Counts connections via pool lifecycle events, so it reports real numbers under
any pool class unlike the pool-object collector, which reads zero under
``NullPool``. Pre-resolving the labeled children materializes both series at 0,
so an absent series signals a broken scrape rather than "no connections" (the
zero-init convention). Idempotent: repeated calls won't attach duplicate
listeners, which would double-count connections.
"""
global _connection_tracker, _db_connection_instrumentation_registered
if not settings.METRICS.ENABLED or _db_connection_instrumentation_registered:
return
open_child = db_connections_open_gauge.labels(instance_type=instance_type)
established_child = db_connections_established_counter.labels(
instance_type=instance_type
)
_connection_tracker = DBConnectionTracker(open_child, established_child)
sync_engine = engine.sync_engine
event.listen(sync_engine, "connect", _connection_tracker.on_connect)
# A connection is torn down by close (normal return / recycle discard),
# invalidate (broken connection), or detach — the last fires on GC-cleanup of an
# abandoned async connection, where NullPool's close is a no-op so `close` never
# fires. All three carry the ConnectionRecord; the marker dedupes if more than
# one fires for the same connection.
for teardown_event in ("close", "invalidate", "detach"):
event.listen(sync_engine, teardown_event, _connection_tracker.on_close)
_db_connection_instrumentation_registered = True
# Define your naming convention
convention = {
"ix": "ix_%(table_name)s_%(column_0_N_name)s", # Index - supports multi-column

View File

@ -6,7 +6,11 @@ import uvloop
from prometheus_client import start_http_server
from src.config import settings
from src.db import engine, register_db_query_instrumentation
from src.db import (
engine,
register_db_connection_instrumentation,
register_db_query_instrumentation,
)
from src.startup import validate_embedding_schema
from src.telemetry import (
initialize_telemetry_async,
@ -26,6 +30,7 @@ def start_metrics_server() -> None:
# Expose DB connection-pool stats for this deriver instance.
register_db_pool_collector("deriver")
register_db_query_instrumentation("deriver")
register_db_connection_instrumentation("deriver")
# region ai
# Zero-init bounded-label counters so a missing series signals a broken scrape,

View File

@ -14,6 +14,7 @@ from src import crud, models
from src.config import ReasoningLevel
from src.dependencies import tracked_db
from src.dialectic.core import DialecticAgent
from src.dialectic.workspace import WorkspaceDialecticAgent
from src.exceptions import ValidationException
from src.utils.config_helpers import get_configuration
from src.utils.scopes import is_scope_peer
@ -205,3 +206,61 @@ async def agentic_chat_stream(
async for chunk in agent.answer_stream(query, response_model=response_model):
yield chunk
async def workspace_chat(
workspace_name: str,
session_name: str | None,
query: str,
reasoning_level: ReasoningLevel = "low",
response_model: type[BaseModel] | None = None,
session_allowlist: list[str] | None = None,
) -> str:
"""Answer a query across all peers in a workspace."""
async with tracked_db("dialectic.workspace_preflight", read_only=True) as db:
await crud.get_workspace(db, workspace_name=workspace_name)
session = None
if session_name:
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
session_id = session.id if session else None
# DB session closed -- agent runs without holding a connection
agent = WorkspaceDialecticAgent(
workspace_name=workspace_name,
session_name=session_name,
session_id=session_id,
reasoning_level=reasoning_level,
session_allowlist=session_allowlist,
)
return await agent.answer(query, response_model=response_model)
async def workspace_chat_stream(
workspace_name: str,
session_name: str | None,
query: str,
reasoning_level: ReasoningLevel = "low",
response_model: type[BaseModel] | None = None,
session_allowlist: list[str] | None = None,
) -> AsyncIterator[str]:
"""Streaming variant of :func:`workspace_chat`."""
async with tracked_db("dialectic.workspace_preflight", read_only=True) as db:
await crud.get_workspace(db, workspace_name=workspace_name)
session = None
if session_name:
session = await crud.get_session(
db, workspace_name=workspace_name, session_name=session_name
)
session_id = session.id if session else None
agent = WorkspaceDialecticAgent(
workspace_name=workspace_name,
session_name=session_name,
session_id=session_id,
reasoning_level=reasoning_level,
session_allowlist=session_allowlist,
)
async for chunk in agent.answer_stream(query, response_model=response_model):
yield chunk

View File

@ -105,7 +105,15 @@ class DialecticAgent:
{
"role": "system",
"content": prompts.agent_system_prompt(
observer, observed, observer_peer_card, observed_peer_card
observer,
observed,
observer_peer_card,
observed_peer_card,
available_tools={
name
for tool in self._select_tools()
if isinstance((name := tool.get("name")), str)
},
),
}
]
@ -303,9 +311,8 @@ class DialecticAgent:
if prefetched_observations:
user_content = (
f"Query: {query}\n\n"
f"## Relevant Observations (prefetched)\n"
f"The following observations were found to be semantically relevant to your query. "
f"Use these as primary context. You may still use tools to find additional information if needed.\n\n"
f"## {self._prefetch_heading()}\n"
f"{self._prefetch_intro()}\n\n"
f"{prefetched_observations}"
)
accumulate_metric(
@ -318,7 +325,14 @@ class DialecticAgent:
tool_executor: Callable[
[str, dict[str, Any]], Any
] = await create_tool_executor(
] = await self._create_tool_executor()
return tool_executor, task_name, run_id, start_time
async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]:
"""Build the tool executor. Subclasses override to change tool scoping
(e.g. WorkspaceDialecticAgent uses the workspace executor)."""
return await create_tool_executor(
workspace_name=self.workspace_name,
session_name=self.session_name,
session_allowlist=self.session_allowlist,
@ -330,7 +344,16 @@ class DialecticAgent:
parent_category="dialectic",
)
return tool_executor, task_name, run_id, start_time
def _prefetch_heading(self) -> str:
"""Heading for the prefetched block in the user message."""
return "Relevant Observations (prefetched)"
def _prefetch_intro(self) -> str:
"""Sentence introducing the prefetched block in the user message."""
return (
"The following observations were found to be semantically relevant to your query. "
"Use these as primary context. You may still use tools to find additional information if needed."
)
def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext:
"""Build the LLMTelemetryContext shared by answer() and answer_stream().

View File

@ -2,24 +2,138 @@
System prompts for the Dialectic Agent.
"""
from collections.abc import Iterable
# Curated tool docs, keyed by the `name` each loadout actually exposes.
# `_select_tools` filters this set per request (minimal / session allowlist).
_PAIR_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
(
"Memory",
[
(
"search_memory",
"Semantic search over conclusions about this pair.",
),
(
"get_reasoning_chain",
"Premises and downstream conclusions for a specific conclusion.",
),
(
"get_observation_context",
"Messages around a specific conclusion.",
),
],
),
(
"Conversation",
[
(
"search_messages",
"Semantic search over messages in this query's scope.",
),
(
"grep_messages",
"Exact text search. Use for names, dates, keywords.",
),
(
"get_messages_by_date_range",
"Messages in a time window.",
),
(
"search_messages_temporal",
"Semantic search with a date filter.",
),
],
),
]
_WORKSPACE_TOOL_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
(
"Discovery",
[
(
"get_workspace_stats",
"Counts (peers, sessions, messages), date range, and the most active peers.",
),
],
),
(
"Memory (pair-scoped — you must name the pair)",
[
(
"search_memory",
"Semantic search over conclusions. Requires `observer` and `observed`. For a peer's own representation, set both to the same name. Use different names only when you want one peer's view of another.",
),
(
"get_peer_card",
"Biographical summary for a pair. Same observer/observed rule.",
),
(
"get_reasoning_chain",
"Premises and downstream conclusions for a specific conclusion.",
),
],
),
(
"Conversation (workspace-wide — results include `peer_name`)",
[
("search_messages", "Semantic search over messages."),
("grep_messages", "Exact text search."),
(
"get_observation_context",
"Messages around a specific conclusion.",
),
("get_messages_by_date_range", "Messages in a time window."),
("search_messages_temporal", "Semantic search with a date filter."),
],
),
]
PAIR_PROMPT_TOOLS: frozenset[str] = frozenset(
name for _, items in _PAIR_TOOL_GROUPS for name, _ in items
)
WORKSPACE_PROMPT_TOOLS: frozenset[str] = frozenset(
name for _, items in _WORKSPACE_TOOL_GROUPS for name, _ in items
)
def _available_tool_names(
available_tools: Iterable[str] | None,
default: frozenset[str],
) -> frozenset[str]:
if available_tools is None:
return default
return frozenset(available_tools)
def _render_tool_groups(
available: frozenset[str],
groups: list[tuple[str, list[tuple[str, str]]]],
) -> str:
parts: list[str] = []
for heading, items in groups:
lines = [f"- `{name}`: {desc}" for name, desc in items if name in available]
if lines:
parts.append(f"**{heading}**\n" + "\n".join(lines))
return "\n\n".join(parts)
def agent_system_prompt(
observer: str,
observed: str,
observer_peer_card: list[str] | None,
observed_peer_card: list[str] | None,
available_tools: Iterable[str] | None = None,
) -> str:
"""
Generate the agent system prompt for the dialectic agent.
"""System prompt for pair-scoped dialectic recall.
Args:
observer: The peer making the query
observed: The peer being queried about
observer_peer_card: Biographical information about the observer
observed_peer_card: Biographical information about the observed peer
Returns:
Formatted system prompt string for the agent
available_tools: Tool names offered on this request. Defaults to the
full pair loadout.
"""
# Determine if we have any peer card data
peer_cards_enabled = (
@ -79,25 +193,25 @@ Peer cards are **constructed summaries** - they are synthesized from the same ob
- The peer card is a convenience summary, not a separate source of truth
"""
return f"""
You are a helpful and concise context synthesis agent that answers questions about users by gathering relevant information from a memory system.
tools = _available_tool_names(available_tools, PAIR_PROMPT_TOOLS)
tools_section = _render_tool_groups(tools, _PAIR_TOOL_GROUPS)
Always give users the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. You have many tools for gathering context. Search wisely.
return f"""
You are Honcho's dialectic: a recall agent that answers questions from memory about one peer, or about one peer's understanding of another.
Honcho is a memory system. Applications record conversations; Honcho derives conclusions about the people and agents in them. You are the query interface for one observer/observed pair. You do not speak as a participant. You search memory and synthesize a grounded answer.
A **peer** is any participant, human or AI. A **session** is a conversation they take part in. A **message** is a raw turn. A **conclusion** (tools may say observation) is a derived or stored fact about a peer, kept in this pair. A **peer card** is a short constructed bio for the pair, synthesized from the same conclusions a convenience summary, not a separate source of truth.
Always give the asker the answer *they expect* based on the message history -- the goal is to help recall and *reason through* insights that the memory system has already gathered. Search wisely.
{perspective_section}
{peer_card_explanation}
## AVAILABLE TOOLS
## TOOLS
**Observation Tools (read):**
- `search_memory`: Semantic search over observations about the peer. Use for specific topics.
- `get_reasoning_chain`: **CRITICAL for grounding answers**. Use this to traverse the reasoning tree for any observation. Shows premises (what it's based on) and conclusions (what depends on it).
Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have.
**Conversation Tools (read):**
- `search_messages`: Semantic search over messages in the session.
- `grep_messages`: Grep for text matches in messages. Use for specific names, dates, keywords.
- `get_observation_context`: Get messages surrounding specific observations.
- `get_messages_by_date_range`: Get messages within a specific time period.
- `search_messages_temporal`: Semantic search with date filtering.
{tools_section}
## WORKFLOW
@ -166,10 +280,6 @@ Always give users the answer *they expect* based on the message history -- the g
- Apply user preferences to your response style if relevant
- **For enumeration questions**: Before answering, ask yourself "Could there be more items I haven't found?" If you haven't done multiple grep searches AND a semantic search, keep searching
8. **Save novel deductions** (optional):
- If you discovered new insights by combining existing observations
- Use `create_observations_deductive` to save these for future queries
## CRITICAL: HANDLING CONTRADICTORY INFORMATION
As you search, actively watch for contradictions - cases where the user has made conflicting statements:
@ -235,3 +345,64 @@ After gathering context, reason through the information you found *before* stati
Do not explain your tool usage - just provide the synthesized answer.
"""
def workspace_agent_system_prompt(
available_tools: Iterable[str] | None = None,
) -> str:
"""System prompt for workspace-wide dialectic recall."""
tools = _available_tool_names(available_tools, WORKSPACE_PROMPT_TOOLS)
tools_section = _render_tool_groups(tools, _WORKSPACE_TOOL_GROUPS)
return f"""
You are Honcho's workspace dialectic: a recall agent that answers questions about everyone and everything stored in this workspace.
## HONCHO
Honcho is a memory system. Applications record conversations here; Honcho derives conclusions about the people and agents in those conversations. You are the query interface over one workspace. You do not speak as a participant. You search memory and synthesize a grounded answer.
## THIS WORKSPACE
A workspace is one isolated tenant. Everything you can see belongs to it. Inside it:
- **Peer**: any participant, human or AI. Both are first-class.
- **Session**: a conversation that one or more peers take part in.
- **Message**: a raw turn someone said in a session. Messages are the source material.
- **Conclusion** (tools may say observation): a fact Honcho derived, or that was stored, about a peer. Conclusions live in a pair:
- `observer` is whose model this is
- `observed` is who the fact is about
- A peer's own model of themselves is `observer` = `observed` = that peer's name. Most information lives there.
- One peer's model of another is `observer` = Alice, `observed` = Bob.
- **Peer card**: a short constructed bio for a pair, synthesized from the same conclusions. It is a convenience summary, not a separate source of truth.
You are not bound to any one peer. Discover who is relevant, then query each pair individually.
## TOOLS
Only the tools listed here are available on this query. If a later step names a tool you do not have, skip that step and use what you do have.
{tools_section}
Message search is how you find peers the overview missed. Memory search is how you learn about a peer once you know their name.
If this query is restricted to a session or a set of sessions, message tools already honor that restriction. Peer cards and reasoning chains may be unavailable then, because they span sessions.
## WORKFLOW
1. **Orient**. Scale and the most active peers are already in your query context. Call `get_workspace_stats` only if you need a refresh. If the query names a peer, go straight to that peer.
2. **Discover**. If you do not know who is relevant, use `search_messages` or `grep_messages`. Hits carry peer names.
3. **Recall**. For each relevant peer, `search_memory(observer=name, observed=name, query=...)`. For comparisons, search each peer separately, then compare. Only use a mixed observer/observed pair when the question is specifically about one peer's understanding of another.
4. **Attribute**. Every fact you state names the peer it is about. If it is a cross-peer view, also name whose model it came from. Example: "Alice is a violinist." / "From Bob's model of Alice, …"
5. **Synthesize**. Answer the question. Quote exact names, dates, and numbers. For aggregations, list findings per peer. Do not narrate tool use.
## NEVER FABRICATE
State only what you found. If you have related context but not the asked-for detail, say what you know and what you don't. "I don't have information about X" is the correct answer when memory is empty. Do not guess, hedge-invent, or fill gaps with general knowledge.
## CONCLUSION LEVELS
`explicit` conclusions are derived from a single session. Deductive and inductive conclusions consolidate across sessions. Prefer those for cross-session or cross-peer answers, and use `get_reasoning_chain` to check their premises.
"""

190
src/dialectic/workspace.py Normal file
View File

@ -0,0 +1,190 @@
"""Workspace-level dialectic agent.
Answers queries across ALL peers in a workspace. Where DialecticAgent is
bound to a single (observer, observed) pair, this agent routes first
workspace stats, active peers, and peer cards are prefetched for
orientation; message search is workspace-flat and reveals which peers
discussed a topic and then recalls through the same pair-scoped
observation machinery, supplying the pair as tool arguments.
Observation search deliberately stays pair-scoped: it matches both the
(observer, observed) collection ownership and the per-pair vector-store
namespaces, and avoids retrieval dilution from a workspace-flat top-k.
Design carried over from plastic-labs/honcho#373 (Dan), re-grown on the
current DialecticAgent seams instead of a base-class extraction.
"""
import logging
from collections.abc import Callable
from typing import Any
from src import crud
from src.config import ReasoningLevel, settings
from src.dependencies import tracked_db
from src.dialectic import prompts
from src.dialectic.core import DialecticAgent
from src.llm.types import LLMTelemetryContext
from src.utils.agent_tools import (
WORKSPACE_DIALECTIC_TOOLS,
WORKSPACE_TOOLS_MINIMAL,
create_workspace_tool_executor,
format_workspace_stats,
)
logger = logging.getLogger(__name__)
# How many active peers (with their self peer cards) to inject at prefetch.
# Routing-obvious queries should resolve without a discovery tool round —
# each avoided tool round is a full model turn (~1.3s measured).
_PREFETCH_ACTIVE_PEERS = 5
class WorkspaceDialecticAgent(DialecticAgent):
"""Dialectic agent scoped to a whole workspace instead of a peer pair."""
def __init__(
self,
workspace_name: str,
session_name: str | None = None,
metric_key: str | None = None,
reasoning_level: ReasoningLevel = "low",
session_id: str | None = None,
session_allowlist: list[str] | None = None,
) -> None:
super().__init__(
workspace_name=workspace_name,
session_name=session_name,
observer="",
observed="",
metric_key=metric_key,
reasoning_level=reasoning_level,
session_id=session_id,
session_allowlist=session_allowlist,
)
# Replace the pair-oriented system prompt with the workspace one.
self.messages[0] = {
"role": "system",
"content": prompts.workspace_agent_system_prompt(
{
name
for tool in self._select_tools()
if isinstance((name := tool.get("name")), str)
}
),
}
# ------------------------------------------------------------------
# DialecticAgent seams
# ------------------------------------------------------------------
async def _prefetch_relevant_observations(self, query: str) -> str | None:
"""Orientation + routing prefetch: stats, active peers, peer cards.
No semantic retrieval here a workspace-flat observation top-k
would be dominated by the most verbose peers. Instead give the
agent what it needs to ROUTE: who is here, who is active, and what
is known about them at a glance.
"""
_ = query
# Like the base agent, prefetch failure degrades to no prefetched
# block rather than failing the whole request (the caller in
# _prepare_query does not guard this).
try:
async with tracked_db("dialectic.workspace_prefetch", read_only=True) as db:
stats = await crud.get_workspace_stats(
db,
self.workspace_name,
session_names=self.session_allowlist,
)
if stats.peer_count == 0:
return None
peers = await crud.get_active_peers(
db,
self.workspace_name,
limit=_PREFETCH_ACTIVE_PEERS,
session_names=self.session_allowlist,
)
# `peers` is already allowlist-filtered, but a peer card is a
# single cross-session aggregate: an in-scope peer's card can
# still carry facts derived from sessions outside the scope.
# Drop cards entirely under an allowlist — same rule the
# get_peer_card tool enforces — and route on stats alone.
cards: dict[str, list[str]] = {}
if self.session_allowlist is None:
for peer in peers:
card = await crud.get_peer_card(
db,
workspace_name=self.workspace_name,
observer=peer.name,
observed=peer.name,
)
if card:
cards[peer.name] = card
except Exception:
logger.warning(
"Failed to prefetch workspace overview for workspace=%s",
self.workspace_name,
exc_info=True,
)
return None
return format_workspace_stats(stats, peers, cards)
def _prefetch_heading(self) -> str:
return "Workspace overview (prefetched)"
def _prefetch_intro(self) -> str:
return (
"Workspace scale, the most active peers, and any known "
"biographical facts about them. Use this to decide who is "
"relevant, then search that peer's own representation with "
"search_memory (observer and observed both set to their name), "
"or search_messages / get_workspace_stats to find peers not "
"listed here."
)
def _select_tools(self) -> list[dict[str, Any]]:
tools = (
WORKSPACE_TOOLS_MINIMAL
if self.reasoning_level == "minimal"
else WORKSPACE_DIALECTIC_TOOLS
)
# Mirror the base agent's allowlist rule, for both tools that cannot
# honor an allowlist: reasoning chains traverse provenance across
# sessions, and a peer card is one cross-session aggregate with no
# per-session attribution. Both fail closed in their handlers too;
# dropping them here avoids paying the schema tokens and a wasted
# turn on a tool that can only refuse.
if self.session_allowlist is not None:
unscopable = {"get_reasoning_chain", "get_peer_card"}
tools = [t for t in tools if t.get("name") not in unscopable]
return tools
async def _create_tool_executor(self) -> Callable[[str, dict[str, Any]], Any]:
return await create_workspace_tool_executor(
workspace_name=self.workspace_name,
session_name=self.session_name,
session_allowlist=self.session_allowlist,
history_token_limit=settings.DIALECTIC.HISTORY_TOKEN_LIMIT,
run_id=self._run_id,
agent_type="workspace_dialectic",
parent_category="dialectic",
)
# Workspace chat shares the base "dialectic_chat" Langfuse trace name;
# scope is distinguished by the agent_type/track_name below.
def _telemetry_context(self, track_name: str | None = None) -> LLMTelemetryContext:
return LLMTelemetryContext(
workspace_name=self.workspace_name,
call_purpose="dialectic.answer",
parent_category="dialectic",
agent_type="workspace_dialectic",
run_id=self._run_id,
trace_id=self._run_id,
span_id=self._run_id,
session_id=self.session_id,
peer_name="(workspace)",
track_name=track_name or "Workspace Dialectic Agent",
)

View File

@ -159,6 +159,17 @@ def _publish_embedding_event(
logger.debug("Failed to emit EmbeddingCallCompletedEvent", exc_info=True)
class EmbeddingTokenLimitError(ValueError):
"""Raised when input text genuinely exceeds the model's token limit.
Subclasses ``ValueError`` so existing broad handlers keep working, while
letting callers tell a real "content too long" condition apart from a
transient provider or configuration failure (dimension mismatch, empty
response, upstream error). Only the pre-flight token checks raise this;
provider failures keep raising plain ``ValueError``.
"""
class BatchItem(NamedTuple):
"""A single item in a batch with its metadata."""
@ -272,7 +283,7 @@ class _EmbeddingClient:
token_count = len(self.encoding.encode(query))
if token_count > self.max_embedding_tokens:
raise ValueError(
raise EmbeddingTokenLimitError(
f"Query exceeds maximum token limit of {self.max_embedding_tokens} tokens (got {token_count} tokens)"
)
@ -358,8 +369,8 @@ class _EmbeddingClient:
List of embedding vectors, one per input text (in order)
Raises:
ValueError: If any text exceeds token limits and `on_oversize` is
``"raise"``
EmbeddingTokenLimitError: If any text exceeds token limits and
`on_oversize` is ``"raise"``
"""
if not texts:
return []
@ -380,7 +391,7 @@ class _EmbeddingClient:
tokens,
)
else:
raise ValueError(
raise EmbeddingTokenLimitError(
f"Text at index {idx} exceeds maximum token limit of "
+ f"{self.max_embedding_tokens} tokens (got {len(token_ids)} tokens)"
)

View File

@ -441,10 +441,19 @@ class OpenAIBackend:
)
cache_creation, cache_read = extract_openai_cache_tokens(usage)
# content_override=None means no override, not "force content to None"
if content_override is not None:
content: Any = content_override
elif message.content is not None:
content = message.content
elif tool_calls:
# Preserve null content on tool-call turns for history replay
content = None
else:
content = ""
return CompletionResult(
content=content_override
if content_override is not None
else (message.content or ""),
content=content,
input_tokens=usage.prompt_tokens if usage else 0,
output_tokens=usage.completion_tokens if usage else 0,
cache_creation_input_tokens=cache_creation,

View File

@ -17,7 +17,12 @@ from sentry_sdk.integrations.starlette import StarletteIntegration
from src._version import HONCHO_VERSION
from src.cache.client import close_cache, init_cache
from src.config import settings
from src.db import engine, register_db_query_instrumentation, request_context
from src.db import (
engine,
register_db_connection_instrumentation,
register_db_query_instrumentation,
request_context,
)
from src.exceptions import HonchoException
from src.routers import (
conclusions,
@ -109,6 +114,7 @@ async def lifespan(_: FastAPI):
# Expose DB connection-pool stats for this API instance (no-op if metrics off)
register_db_pool_collector("api")
register_db_query_instrumentation("api")
register_db_connection_instrumentation("api")
# region ai
# Zero-init bounded-label counters so a missing series signals a broken scrape,

View File

@ -5,7 +5,6 @@ import logging
from collections.abc import AsyncIterator
from contextlib import suppress
from time import perf_counter
from typing import Any
from fastapi import APIRouter, Body, Depends, Path, Query, Response
from fastapi.responses import StreamingResponse
@ -35,6 +34,7 @@ from src.utils.scopes import (
is_scope_peer,
is_scope_peer_name,
validate_no_scope_peer_names,
validate_scope_read_option,
)
from src.utils.search import search
from src.utils.types import embedding_call_purpose
@ -47,33 +47,6 @@ router = APIRouter(
)
def _validate_scope_option(
*,
filters: dict[str, Any] | None,
session_id: str | None,
jwt_params: JWTParams,
) -> None:
"""Enforce the v1 `scope` exclusions and auth rule (chat/representation).
`scope` is mutually exclusive with `filters` and `session_id` (422), and a
scope's member sessions may exceed a peer's own membership, so scoped
reads require a workspace- or admin-level key.
401 rather than 403: every other scope surface refuses a narrow key with 401
the `/scopes` router via `require_auth`, and the `scopes` field on session
create so a peer key would otherwise get two different codes for the same
feature depending on which side of it was touched.
"""
if filters is not None:
raise ValidationException("`scope` and `filters` are mutually exclusive")
if session_id:
raise ValidationException("`scope` and `session_id` are mutually exclusive")
if jwt_params.p is not None:
raise AuthenticationException(
"`scope` requires a workspace- or admin-level key"
)
async def _resolve_scope_option(
workspace_id: str,
scope: str | list[str],
@ -95,16 +68,7 @@ async def _resolve_scope_option(
)
return scope_peer, None
scope_peers = await crud.resolve_scope_peers(scope_db, workspace_id, scope)
union: list[str] = []
seen: set[str] = set()
for scope_peer in scope_peers:
for session_name in await get_peer_session_names(
scope_db, workspace_id, scope_peer
):
if session_name not in seen:
seen.add(session_name)
union.append(session_name)
union = await crud.resolve_scope_session_union(scope_db, workspace_id, scope)
if len(union) > MAX_SESSION_ALLOWLIST_ENTRIES:
raise ValidationException(
@ -316,7 +280,7 @@ async def chat(
observer = peer_id
scope_session_union: list[str] | None = None
if options.scope is not None:
_validate_scope_option(
validate_scope_read_option(
filters=options.filters,
session_id=options.session_id,
jwt_params=jwt_params,
@ -506,7 +470,7 @@ async def get_representation(
observer = peer_id
scope_session_union: list[str] | None = None
if options.scope is not None:
_validate_scope_option(
validate_scope_read_option(
filters=options.filters,
session_id=options.session_id,
jwt_params=jwt_params,

View File

@ -1,10 +1,14 @@
"""FastAPI routes for workspace resources and workspace-scoped operations."""
import json
import logging
from collections.abc import AsyncIterator
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
from fastapi.responses import StreamingResponse
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import apaginate
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
@ -12,8 +16,13 @@ from src.config import settings
from src.crud.message import get_peer_session_names
from src.dependencies import db, read_db, tracked_db
from src.deriver.enqueue import enqueue_deletion, enqueue_dream
from src.dialectic.chat import workspace_chat, workspace_chat_stream
from src.exceptions import AuthenticationException, ValidationException
from src.security import JWTParams, require_auth
from src.telemetry import prometheus_metrics
from src.utils.filter import MAX_SESSION_ALLOWLIST_ENTRIES
from src.utils.schema_conversion import json_response_schema_to_pydantic
from src.utils.scopes import validate_scope_read_option
from src.utils.search import search
logger = logging.getLogger(__name__)
@ -276,3 +285,92 @@ async def schedule_dream(
observed,
request.session_id,
)
@router.post(
"/{workspace_id}/chat",
responses={
200: {
"content": {
"application/json": {
"schema": schemas.DialecticResponse.model_json_schema()
},
"text/event-stream": {},
},
},
},
)
async def chat(
workspace_id: str = Path(...),
options: schemas.WorkspaceChatOptions = Body(...),
jwt_params: JWTParams = Depends(require_auth(workspace_name="workspace_id")),
):
"""Query the entire workspace using natural language.
Pass `scope` to restrict recall to the union of those scopes' member
sessions. A scope with no member sessions recalls nothing (fail-closed).
"""
session_allowlist: list[str] | None = None
if options.scope is not None:
validate_scope_read_option(
filters=None,
session_id=options.session_id,
jwt_params=jwt_params,
)
names = [options.scope] if isinstance(options.scope, str) else options.scope
async with tracked_db(
"workspaces.chat.resolve_scope", read_only=True
) as scope_db:
session_allowlist = await crud.resolve_scope_session_union(
scope_db, workspace_id, names
)
if len(session_allowlist) > MAX_SESSION_ALLOWLIST_ENTRIES:
raise ValidationException(
"The scopes' combined membership exceeds the maximum of "
+ f"{MAX_SESSION_ALLOWLIST_ENTRIES} sessions per request"
)
response_model: type[BaseModel] | None = None
if options.response_format is not None:
try:
response_model = json_response_schema_to_pydantic(options.response_format)
except ValueError as e:
raise ValidationException(f"Invalid response_format: {e}") from None
if settings.METRICS.ENABLED:
prometheus_metrics.record_dialectic_call(
workspace_name=workspace_id,
reasoning_level=options.reasoning_level,
)
if options.stream:
async def format_sse_stream(chunks: AsyncIterator[str]) -> AsyncIterator[str]:
"""Format chunks as SSE events."""
async for chunk in chunks:
yield f"data: {json.dumps({'delta': {'content': chunk}, 'done': False})}\n\n"
yield f"data: {json.dumps({'done': True})}\n\n"
return StreamingResponse(
format_sse_stream(
workspace_chat_stream(
workspace_name=workspace_id,
session_name=options.session_id,
query=options.query,
reasoning_level=options.reasoning_level,
response_model=response_model,
session_allowlist=session_allowlist,
)
),
media_type="text/event-stream",
)
response = await workspace_chat(
workspace_name=workspace_id,
session_name=options.session_id,
query=options.query,
reasoning_level=options.reasoning_level,
response_model=response_model,
session_allowlist=session_allowlist,
)
return schemas.DialecticResponse(content=response if response else None)

View File

@ -54,6 +54,7 @@ from src.schemas.api import (
WebhookEndpointCreate,
Workspace,
WorkspaceBase,
WorkspaceChatOptions,
WorkspaceCreate,
WorkspaceGet,
WorkspaceMessageSearchOptions,
@ -114,6 +115,7 @@ __all__ = [
"ConclusionQuery",
"DialecticOptions",
"DialecticResponse",
"WorkspaceChatOptions",
"DialecticStreamChunk",
"DialecticStreamDelta",
"Message",

View File

@ -61,6 +61,15 @@ def _sanitize_value(v: Any) -> Any:
return v
def _strip_nul(v: str) -> str:
"""Strip NUL bytes from a string field (Postgres TEXT rejects \\x00)."""
return v.replace("\x00", "")
# Reusable annotation for query fields; composes with a per-field Field(...).
NulStripped = AfterValidator(_strip_nul)
def _check_metadata_limits(
data: dict[str, Any],
*,
@ -718,7 +727,7 @@ class ConclusionBatchCreate(BaseModel):
class MessageSearchOptions(BaseModel):
query: Annotated[str, Field(..., description="Search query")]
query: Annotated[str, Field(..., description="Search query"), NulStripped]
filters: dict[str, Any] | None = Field(
default=None, description="Filters to scope the search"
)
@ -729,11 +738,6 @@ class MessageSearchOptions(BaseModel):
description="Number of results to return",
)
@field_validator("query", mode="after")
@classmethod
def sanitize_query(cls, v: str) -> str:
return v.replace("\x00", "")
class WorkspaceMessageSearchOptions(MessageSearchOptions):
"""Workspace-level message search options, extended with `scope`."""
@ -786,7 +790,9 @@ class DialecticOptions(BaseModel):
description="Optional peer to get the representation for, from the perspective of this peer",
)
query: Annotated[
str, Field(min_length=1, max_length=10000, description="Dialectic API Prompt")
str,
Field(min_length=1, max_length=10000, description="Dialectic API Prompt"),
NulStripped,
]
stream: bool = False
reasoning_level: ReasoningLevel = Field(
@ -804,10 +810,39 @@ class DialecticOptions(BaseModel):
),
)
@field_validator("query", mode="after")
@classmethod
def sanitize_query(cls, v: str) -> str:
return v.replace("\x00", "")
class WorkspaceChatOptions(BaseModel):
"""Options for workspace-level chat (no anchor peer; see DialecticOptions)."""
session_id: str | None = Field(
None, description="Optional session to scope message tools to"
)
query: Annotated[
str,
Field(min_length=1, max_length=10000, description="Workspace chat prompt"),
NulStripped,
]
stream: bool = False
reasoning_level: ReasoningLevel = Field(
default="low",
description="Level of reasoning to apply: minimal, low, medium, high, or max",
)
response_format: dict[str, Any] | None = Field(
None,
description=(
"Optional JSON Schema (root type 'object') the response must conform"
" to. When provided, `content` is a JSON string matching this schema."
),
)
scope: _ScopeOption | None = Field(
None,
description=(
"Optional (unprefixed) scope name(s) restricting recall to the "
"union of the scopes' member sessions (explicit allowlist, "
"fail-closed: an empty union recalls nothing). Mutually exclusive "
"with `session_id`. Requires a workspace- or admin-level key."
),
)
class DialecticResponse(BaseModel):

View File

@ -208,6 +208,27 @@ db_queries_in_flight_gauge = NamespacedGauge(
["namespace", "instance_type"],
)
# Physical DB connections, tracked via SQLAlchemy connection-lifecycle events
# (see DBConnectionTracker in src/db.py) rather than the pool object, so they are
# visible under EVERY pool class — including NullPool, whose pool holds no records
# for the scrape-time db_pool_connections collector to read. Under QueuePool this
# roughly equals db_pool_connections{checked_in} + {checked_out}; its unique value
# is under NullPool, where that collector reads zero.
db_connections_open_gauge = NamespacedGauge(
"db_connections_open",
"Physical DB connections currently open by this instance, across all pool "
+ "classes (tracks concurrency of DB work under NullPool, pool occupancy "
+ "under QueuePool)",
["namespace", "instance_type"],
)
db_connections_established_counter = NamespacedCounter(
"db_connections_established",
"Physical DB connections established since process start. Under NullPool, "
+ "rate() approximates request rate (one connect per DB checkout)",
["namespace", "instance_type"],
)
@final
class PrometheusMetrics:
@ -409,6 +430,11 @@ class PrometheusMetrics:
"""Pre-create bounded-label counter children at 0 for this process, so an
absent series means a broken scrape rather than "nothing happened".
Note: the DB-instrumentation metrics (db_queries_in_flight,
db_connections_open, db_connections_established) are NOT initialized here
they zero-init via the pre-resolved labeled children in their register_db_*
functions in src/db.py, so an auditor should not read them as forgotten.
Args:
instance_type: "api" or "deriver" selects the process-specific
counters. Event-type and buffer metrics are initialized in both.

View File

@ -2,7 +2,7 @@ import asyncio
import logging
import weakref
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime
from typing import Any, cast
@ -13,7 +13,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.exceptions import ResourceNotFoundException
from src.models import Document
from src.schemas import ResolvedConfiguration
from src.telemetry.events import (
@ -291,6 +292,11 @@ def _safe_int(value: Any, default: int) -> int:
return default
def _bounded_int(value: Any, default: int, *, lo: int = 1, hi: int) -> int:
"""Coerce a tool int into ``[lo, hi]``, falling back to ``default`` on bad input."""
return max(lo, min(_safe_int(value, default), hi))
# Module-level lock registry for thread-safe observation creation.
# Keyed by (workspace_name, observer, observed) to ensure all tool executors
# operating on the same data share the same lock.
@ -785,6 +791,59 @@ TOOLS: dict[str, dict[str, Any]] = {
"required": ["observation_id"],
},
},
"search_memory_workspace": {
"name": "search_memory",
"description": "Search within a specific peer representation's memory using semantic similarity. You MUST specify observer and observed. To get a peer's global representation, set observer AND observed to the SAME peer name (this is where most information lives). Only use different observer/observed when seeking one peer's specific understanding of another.",
"input_schema": {
"type": "object",
"properties": {
"observer": {
"type": "string",
"description": "Name of the observer peer",
},
"observed": {
"type": "string",
"description": "Name of the observed peer",
},
"query": {
"type": "string",
"description": "Search query text",
},
"top_k": {
"type": "integer",
"description": "(Optional) number of results to return (default: 20, max: 40)",
"default": 20,
},
},
"required": ["observer", "observed", "query"],
},
},
"get_workspace_stats": {
"name": "get_workspace_stats",
"description": "Get workspace-level statistics — peer count, session count, message count, date range of messages — plus the most recently active peers with their message counts and last-active timestamps. Use this to orient yourself and discover which peers are most relevant.",
"input_schema": {
"type": "object",
"properties": {},
},
},
"get_peer_card_by_name": {
"name": "get_peer_card",
"description": "Get the peer card for a specific peer relationship. Specify the observer and observed peer names.",
"input_schema": {
"type": "object",
"properties": {
"observer": {
"type": "string",
"description": "Name of the observer peer",
},
"observed": {
"type": "string",
"description": "Name of the observed peer",
},
},
"required": ["observer", "observed"],
},
},
}
# Tools for the dialectic agent (analysis)
@ -806,6 +865,31 @@ DIALECTIC_TOOLS_MINIMAL: list[dict[str, Any]] = [
TOOLS["search_messages"],
]
# Tools for the workspace-level dialectic agent. Observation search stays
# pair-scoped (observer/observed are TOOL ARGUMENTS the agent must supply
# after routing) -- matching both the (observer, observed) collection
# ownership and the per-pair vector-store namespaces. Message tools are
# workspace-flat and double as the routing signal (results carry peer_name).
WORKSPACE_DIALECTIC_TOOLS: list[dict[str, Any]] = [
TOOLS["get_workspace_stats"],
TOOLS["search_memory_workspace"],
TOOLS["search_messages"],
TOOLS["get_observation_context"],
TOOLS["grep_messages"],
TOOLS["get_peer_card_by_name"],
TOOLS["get_messages_by_date_range"],
TOOLS["search_messages_temporal"],
TOOLS["get_reasoning_chain"],
]
# Reduced workspace loadout for reasoning_level="minimal" (token cost of the
# tool definitions themselves), mirroring DIALECTIC_TOOLS_MINIMAL.
WORKSPACE_TOOLS_MINIMAL: list[dict[str, Any]] = [
TOOLS["get_workspace_stats"],
TOOLS["search_memory_workspace"],
TOOLS["search_messages"],
]
# Tools for the dreamer agent (consolidation + peer card + deduplication)
DREAMER_TOOLS: list[dict[str, Any]] = [
# Preference extraction (should be called first)
@ -1194,10 +1278,19 @@ async def get_observation_context(
if not message_ids:
return []
from src.crud.message import resolve_session_scope
from src.crud.message import resolve_session_scope_clauses
allowed_session_names, deny = await resolve_session_scope(
db, workspace_name, session_name, session_allowlist, observer
# Scope as SQL rather than as a fetched name list. The scope is applied to
# both the CTE and the outer select, so a materialized list would spend two
# bind parameters per session the observer belongs to — enough sessions and
# the statement exceeds the driver's 65535-parameter ceiling and cannot be
# sent at all.
scope_clauses, deny = resolve_session_scope_clauses(
workspace_name,
session_name,
session_allowlist,
observer,
models.Message.session_name,
)
if deny:
return []
@ -1211,8 +1304,8 @@ async def get_observation_context(
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
for clause in scope_clauses:
stmt = stmt.where(clause)
target_seqs_cte = stmt.cte("target_seqs")
@ -1235,8 +1328,8 @@ async def get_observation_context(
if session_name:
stmt = stmt.where(models.Message.session_name == session_name)
elif allowed_session_names is not None:
stmt = stmt.where(models.Message.session_name.in_(allowed_session_names))
for clause in scope_clauses:
stmt = stmt.where(clause)
result = await db.execute(stmt)
messages = list(result.scalars().all())
@ -1795,7 +1888,7 @@ async def _handle_search_memory(
"""Handle search_memory tool."""
from src.utils.types import ToolResult
top_k = min(_safe_int(tool_input.get("top_k"), 20), 40)
top_k = _bounded_int(tool_input.get("top_k"), 20, hi=40)
query = tool_input["query"]
try:
with embedding_call_purpose(
@ -1805,11 +1898,15 @@ async def _handle_search_memory(
parent_category=ctx.parent_category,
):
query_embedding = await embedding_client.embed(query)
except ValueError:
except EmbeddingTokenLimitError:
return (
"ERROR: Query exceeds maximum token limit of "
+ f"{settings.EMBEDDING.MAX_INPUT_TOKENS}. Please use a shorter query."
)
except ValueError as e:
# Provider/config failure, not an oversized query. Keep returning a
# string so the tool loop can continue, but don't blame the query.
return f"ERROR: Embedding the query failed: {e}"
# Base telemetry metadata; results_count gets filled in below.
search_meta: dict[str, Any] = {
@ -1851,8 +1948,8 @@ async def _handle_search_memory(
# here, we automatically search the message history for relevant
# information.
zero_hit_meta = {**search_meta, "results_count": 0}
if ctx.agent_type == "dialectic":
limit = min(_safe_int(tool_input.get("top_k"), 20), 20)
if ctx.agent_type in ("dialectic", "workspace_dialectic"):
limit = _bounded_int(tool_input.get("top_k"), 20, hi=20)
message_output = None
snippets = await crud.search_messages(
workspace_name=ctx.workspace_name,
@ -1903,7 +2000,7 @@ async def _handle_get_observation_context(
workspace_name=ctx.workspace_name,
session_name=ctx.session_name,
message_ids=tool_input["message_ids"],
observer=ctx.observer,
observer=ctx.observer or None,
session_allowlist=ctx.session_allowlist,
)
if not messages:
@ -1929,7 +2026,7 @@ async def _handle_search_messages(
from src.utils.types import ToolResult
query = tool_input["query"]
limit = min(_safe_int(tool_input.get("limit"), 10), 20) # Cap at 20
limit = _bounded_int(tool_input.get("limit"), 10, hi=20)
# Pre-compute embedding outside DB session to avoid holding a connection
# during the external API call (same pattern as _handle_search_memory).
with embedding_call_purpose(
@ -1946,7 +2043,7 @@ async def _handle_search_messages(
limit=limit,
context_window=2,
embedding=query_embedding,
observer=ctx.observer,
observer=ctx.observer or None,
session_allowlist=ctx.session_allowlist,
)
search_meta: dict[str, Any] = {
@ -1972,10 +2069,8 @@ async def _handle_grep_messages(
text = tool_input.get("text", "")
if not text:
return "ERROR: 'text' parameter is required"
limit = min(_safe_int(tool_input.get("limit"), 10), 30) # Cap at 30
context_window = min(
_safe_int(tool_input.get("context_window"), 2), 2
) # Cap context
limit = _bounded_int(tool_input.get("limit"), 10, hi=30)
context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2)
snippets = await crud.grep_messages(
workspace_name=ctx.workspace_name,
@ -1983,7 +2078,7 @@ async def _handle_grep_messages(
text=text,
limit=limit,
context_window=context_window,
observer=ctx.observer,
observer=ctx.observer or None,
session_allowlist=ctx.session_allowlist,
)
if not snippets:
@ -2028,7 +2123,7 @@ async def _handle_get_messages_by_date_range(
"""Handle get_messages_by_date_range tool."""
after_date_str = tool_input.get("after_date")
before_date_str = tool_input.get("before_date")
limit = min(_safe_int(tool_input.get("limit"), 20), 20)
limit = _bounded_int(tool_input.get("limit"), 20, hi=20)
order = tool_input.get("order", "desc")
after_date = _parse_date(after_date_str, "after_date")
@ -2048,7 +2143,7 @@ async def _handle_get_messages_by_date_range(
before_date=before_date,
limit=limit,
order=order,
observer=ctx.observer,
observer=ctx.observer or None,
session_allowlist=ctx.session_allowlist,
)
msg_count = len(messages)
@ -2094,8 +2189,8 @@ async def _handle_search_messages_temporal(
after_date_str = tool_input.get("after_date")
before_date_str = tool_input.get("before_date")
limit = min(_safe_int(tool_input.get("limit"), 10), 10)
context_window = min(_safe_int(tool_input.get("context_window"), 2), 2)
limit = _bounded_int(tool_input.get("limit"), 10, hi=10)
context_window = _bounded_int(tool_input.get("context_window"), 2, lo=0, hi=2)
after_date = _parse_date(after_date_str, "after_date")
if isinstance(after_date, str):
@ -2124,7 +2219,7 @@ async def _handle_search_messages_temporal(
context_window=context_window,
session_allowlist=ctx.session_allowlist,
embedding=query_embedding,
observer=ctx.observer,
observer=ctx.observer or None,
)
date_filter: list[str] = []
if after_date_str:
@ -2165,7 +2260,7 @@ async def _handle_get_recent_observations(
workspace_name=ctx.workspace_name,
observer=ctx.observer,
observed=ctx.observed,
limit=min(_safe_int(tool_input.get("limit"), 10), 100),
limit=_bounded_int(tool_input.get("limit"), 10, hi=100),
session_name=ctx.session_name if session_only else None,
)
representation = Representation.from_documents(documents)
@ -2191,7 +2286,7 @@ async def _handle_get_most_derived_observations(
workspace_name=ctx.workspace_name,
observer=ctx.observer,
observed=ctx.observed,
limit=min(_safe_int(tool_input.get("limit"), 10), 100),
limit=_bounded_int(tool_input.get("limit"), 10, hi=100),
)
representation = Representation.from_documents(documents)
total_count = representation.len()
@ -2229,6 +2324,16 @@ async def _handle_get_session_summary(
async def _handle_get_peer_card(ctx: ToolContext, tool_input: dict[str, Any]) -> str:
"""Handle get_peer_card tool."""
_ = tool_input
# A peer card lives in Peer.internal_metadata as a single cross-session
# aggregate, so it carries no session attribution and cannot be filtered
# to an allowlist. Fail closed rather than leak facts derived from
# out-of-scope sessions, the same rule get_reasoning_chain follows.
# No-op for agents that never set an allowlist (dreamer, pair dialectic).
if ctx.session_allowlist is not None:
return (
"Peer cards are unavailable for session-scoped queries. "
"Use search_memory instead."
)
async with tracked_db("tool.get_peer_card", read_only=True) as db:
peer_card = await crud.get_peer_card(
db,
@ -2513,6 +2618,7 @@ async def create_tool_executor(
agent_type: str | None = None,
parent_category: str | None = None,
session_allowlist: list[str] | None = None,
handler_resolver: Callable[[str], Any] | None = None,
) -> Callable[[str, dict[str, Any]], Any]:
"""
Create a unified tool executor function for all agent operations.
@ -2535,6 +2641,11 @@ async def create_tool_executor(
run_id: Optional run ID for telemetry correlation
agent_type: Optional agent type for telemetry (dialectic, deriver, dreamer)
parent_category: Optional parent category for CloudEvents
session_allowlist: Optional list of session names message tools are
restricted to (None means no restriction)
handler_resolver: Optional callback that replaces the default
handler-table lookup for resolving tool names to handlers.
Returning None takes the "Unknown tool" path.
Returns:
An async callable that executes tools with the captured context
@ -2598,7 +2709,7 @@ async def create_tool_executor(
tool_obs = _begin_tool_observation(tool_name, tool_input)
try:
handler = _TOOL_HANDLERS.get(tool_name)
handler = (handler_resolver or _TOOL_HANDLERS.get)(tool_name)
if handler:
handler_result = await handler(ctx, tool_input)
# Handlers return either a plain str (existing contract) or a
@ -2796,3 +2907,181 @@ def _estimate_tokens_safe(text: str | None) -> int | None:
if not text:
return None
return _estimate_tokens(text)
# ---------------------------------------------------------------------------
# Workspace-level tool handlers (workspace chat)
#
# The workspace agent is not bound to an (observer, observed) pair. Handlers
# that need a pair take it from tool_input (the agent routes first, then
# supplies the pair); the rest are workspace-scoped reads. Message-search
# fallthrough handlers run with observer="" and normalize it to None at the
# crud boundary (`ctx.observer or None`) -- None means "no perspective
# scoping", which is correct for a workspace-level read. The empty string
# must never reach resolve_session_scope: it would be looked up as a real
# peer with no session memberships and deny all results.
# ---------------------------------------------------------------------------
async def _handle_search_memory_workspace(
ctx: ToolContext, tool_input: dict[str, Any]
) -> "str | ToolResult":
"""Pair-scoped observation search; the pair comes from tool arguments."""
observer = tool_input.get("observer", "")
observed = tool_input.get("observed", "")
if not observer or not observed:
return (
"ERROR: 'observer' and 'observed' are required. For a peer's "
"global representation set both to the SAME peer name."
)
pair_ctx = replace(ctx, observer=observer, observed=observed)
result = await _handle_search_memory(pair_ctx, tool_input)
# Attribute the pair in the output — the workspace agent may query
# several pairs in one turn and must not conflate their results.
if isinstance(result, ToolResult):
return replace(result, content=f"[{observer}->{observed}]\n{result.content}")
return f"[{observer}->{observed}]\n{result}"
async def _handle_get_peer_card_by_name(
ctx: ToolContext, tool_input: dict[str, Any]
) -> str:
"""get_peer_card with the pair taken from tool arguments."""
observer = tool_input.get("observer", "")
observed = tool_input.get("observed", "")
if not observer or not observed:
return "ERROR: 'observer' and 'observed' are required parameters"
pair_ctx = replace(ctx, observer=observer, observed=observed)
try:
return await _handle_get_peer_card(pair_ctx, tool_input)
except ResourceNotFoundException:
# The workspace agent names peers from its own routing, so guessing a
# peer that doesn't exist is an expected turn, not a fault. Answer the
# model instead of letting the executor log it as an unexpected error.
return f"No peer named '{observer}' exists in this workspace"
# Peers listed by get_workspace_stats. Fixed rather than a tool argument:
# folding active peers into stats keeps the tool zero-arg (one discovery
# round instead of two); deeper discovery goes through search_messages.
_STATS_ACTIVE_PEERS = 10
# Peer-card facts listed per peer when cards are supplied.
_STATS_CARD_FACTS = 8
def format_workspace_stats(
stats: "crud.WorkspaceStats",
peers: "Sequence[crud.ActivePeer]",
cards: dict[str, list[str]] | None = None,
) -> str:
"""Render workspace counts and most-active peers as prompt-ready lines.
Shared by the get_workspace_stats tool and WorkspaceDialecticAgent's
routing prefetch; the prefetch passes ``cards`` to nest each peer's
known biographical facts under it.
"""
lines = [
f"Peers: {stats.peer_count}",
f"Sessions: {stats.session_count}",
f"Messages: {stats.message_count}",
]
if stats.oldest_message_at and stats.newest_message_at:
lines.append(
f"Date range: {stats.oldest_message_at:%Y-%m-%d} to {stats.newest_message_at:%Y-%m-%d}"
)
if peers:
lines.append("")
lines.append(f"Most active peers (top {len(peers)}):")
for peer in peers:
last_active = (
f", last active {peer.last_message_at:%Y-%m-%d}"
if peer.last_message_at
else ""
)
lines.append(f"- {peer.name} ({peer.message_count} messages{last_active})")
for fact in (cards or {}).get(peer.name, [])[:_STATS_CARD_FACTS]:
lines.append(f" - {fact}")
return "\n".join(lines)
async def _handle_get_workspace_stats(
ctx: ToolContext, tool_input: dict[str, Any]
) -> str:
"""Workspace-level counts, message date range, and most active peers."""
_ = tool_input
async with tracked_db("workspace_tool.get_workspace_stats", read_only=True) as db:
stats = await crud.get_workspace_stats(
db, ctx.workspace_name, session_names=ctx.session_allowlist
)
peers = await crud.get_active_peers(
db,
ctx.workspace_name,
limit=_STATS_ACTIVE_PEERS,
session_names=ctx.session_allowlist,
)
return "Workspace stats:\n" + format_workspace_stats(stats, peers)
# Dispatch table consulted before _TOOL_HANDLERS by the workspace executor.
_WORKSPACE_TOOL_HANDLERS: dict[str, Callable[[ToolContext, dict[str, Any]], Any]] = {
"search_memory": _handle_search_memory_workspace,
"get_workspace_stats": _handle_get_workspace_stats,
"get_peer_card": _handle_get_peer_card_by_name,
"get_reasoning_chain": _handle_get_reasoning_chain, # already workspace-scoped
}
# Standard handlers that are safe with an empty observer/observed sentinel
# (they only read messages, treating observer="" as unscoped visibility).
_WORKSPACE_SAFE_FALLTHROUGH_TOOLS: frozenset[str] = frozenset(
{
"get_observation_context",
"search_messages",
"grep_messages",
"get_messages_by_date_range",
"search_messages_temporal",
}
)
def _workspace_handler_resolver(tool_name: str) -> Any:
handler = _WORKSPACE_TOOL_HANDLERS.get(tool_name)
if handler is not None:
return handler
if tool_name in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS:
return _TOOL_HANDLERS.get(tool_name)
return None
async def create_workspace_tool_executor(
workspace_name: str,
session_name: str | None = None,
session_allowlist: list[str] | None = None,
history_token_limit: int = 8192,
run_id: str | None = None,
agent_type: str | None = None,
parent_category: str | None = None,
) -> Callable[[str, dict[str, Any]], Any]:
"""Tool executor for workspace-level operations (no bound peer pair).
Reuses create_tool_executor's telemetry/error plumbing via the
handler_resolver seam. observer/observed are empty-string sentinels only
ever seen by handlers in _WORKSPACE_SAFE_FALLTHROUGH_TOOLS, which
normalize them to None before hitting crud (None means "no perspective
scoping"; an empty string would read as a real peer with no sessions and
deny everything).
"""
return await create_tool_executor(
workspace_name=workspace_name,
observer="",
observed="",
session_name=session_name,
session_allowlist=session_allowlist,
include_observation_ids=True,
history_token_limit=history_token_limit,
run_id=run_id,
agent_type=agent_type,
parent_category=parent_category,
handler_resolver=_workspace_handler_resolver,
)

View File

@ -689,7 +689,9 @@ def _build_comparison_condition(
"lte": lambda a, v: a <= v,
"gt": lambda a, v: a > v,
"lt": lambda a, v: a < v,
"ne": lambda a, v: a != v,
# IS DISTINCT FROM, not <>: (metadata ->> key) is NULL for an
# absent key, and `NULL <> v` is NULL, so <> drops those rows.
"ne": lambda a, v: a.is_distinct_from(v),
}
return operator_map[operator](safe_accessor, safe_value)
except Exception as e:

View File

@ -19,7 +19,8 @@ carries a look-alike ``configuration``, is not a scope.
from collections.abc import Iterable
from typing import Any
from src.exceptions import ValidationException
from src.exceptions import AuthenticationException, ValidationException
from src.security import JWTParams
# Reserved peer-name prefix for scope peers. User-created peers may not use it.
#
@ -87,3 +88,20 @@ def validate_no_scope_peer_names(names: Iterable[str], *, action: str) -> None:
f"Peer name(s) {offenders} use the reserved scope prefix "
+ f"'{SCOPE_PEER_PREFIX}'. {action}"
)
def validate_scope_read_option(
*,
filters: dict[str, Any] | None,
session_id: str | None,
jwt_params: JWTParams,
) -> None:
"""Refuse `scope` combined with `filters`/`session_id`, or a peer-scoped key."""
if filters is not None:
raise ValidationException("`scope` and `filters` are mutually exclusive")
if session_id:
raise ValidationException("`scope` and `session_id` are mutually exclusive")
if jwt_params.p is not None:
raise AuthenticationException(
"`scope` requires a workspace- or admin-level key"
)

View File

@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import models
from src.config import settings
from src.dependencies import tracked_db
from src.embedding_client import embedding_client
from src.embedding_client import EmbeddingTokenLimitError, embedding_client
from src.exceptions import ValidationException
from src.models import session_peers_table
from src.telemetry.events import EmbeddingCallPurpose
@ -82,6 +82,9 @@ async def query_external_vector_message_ids(
filters: dict[str, Any] | None = None,
) -> list[str]:
"""Query the external vector store and return ordered message IDs."""
if limit <= 0:
return []
external_vector_store = get_external_vector_store()
if external_vector_store is None:
return []
@ -388,7 +391,7 @@ async def search(
parent_category="api",
):
query_embedding = await embedding_client.embed(query)
except ValueError as e:
except EmbeddingTokenLimitError as e:
raise ValidationException(
f"Query exceeds maximum token limit of {settings.EMBEDDING.MAX_INPUT_TOKENS}."
) from e

Some files were not shown because too many files have changed in this diff Show More