Merge remote-tracking branch 'origin/main' into abigail/dev-2219

# Conflicts:
#	sdks/python/src/honcho/__init__.py
#	sdks/python/src/honcho/aio.py
#	sdks/python/src/honcho/client.py
#	sdks/python/src/honcho/conclusions.py
#	sdks/typescript/__tests__/conclusions.test.ts
#	sdks/typescript/src/conclusions.ts
#	sdks/typescript/src/index.ts
#	tests/sdk/test_conclusions.py
This commit is contained in:
ajspig 2026-08-31 15:40:37 -04:00
commit 920fed07a4
213 changed files with 15575 additions and 2172 deletions

5
.gitattributes vendored Normal file
View File

@ -0,0 +1,5 @@
# Shell entrypoints are executed with sh/dash inside the container image. A
# Windows checkout with core.autocrlf=true rewrites them to CRLF, and dash
# then aborts with "set: Illegal option" because the carriage return becomes
# part of the "-e" flag argument (docker/entrypoint.sh).
*.sh text eol=lf

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.

13
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,13 @@
## Description
<!-- 2-3 sentences about what problem this PR solves and how -->
## Proofs
<!-- Add screenshots, logs, files as a proof that this change works -->
## Checklist
- [ ] This PR is correlated to an existing issue, and I understand it will be closed if that issue does not have the `maintainer-approved` label.
<!-- Fixes #XXX -->

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

View File

@ -12,6 +12,7 @@ on:
- 'tests/live_llm/**'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- '.github/workflows/live-llm-tests.yml'
# Manual trigger for PRs: add the `run-live-llm` label to run the suite
# against the PR's merge commit. The label is purged as soon as the run
@ -111,7 +112,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
python-version-file: ".python-version"
- name: Install the project
run: uv sync --all-extras

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

@ -16,7 +16,7 @@ jobs:
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
python-version-file: ".python-version"
- name: Install uv
uses: astral-sh/setup-uv@v2
with:
@ -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:
@ -148,8 +151,8 @@ jobs:
- name: Verify uv and Python
run: |
uv --version
python3.12 --version
which python3.12
python3.13 --version
which python3.13
- name: Install the project
run: uv sync --all-extras

View File

@ -11,6 +11,7 @@ on:
- '**.jsx'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- 'sdks/typescript/package.json'
- 'sdks/typescript/bun.lock'
- '.github/workflows/unittest.yml'
@ -24,6 +25,7 @@ on:
- '**.jsx'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- 'sdks/typescript/package.json'
- 'sdks/typescript/bun.lock'
- '.github/workflows/unittest.yml'
@ -48,6 +50,7 @@ jobs:
- '**.py'
- 'pyproject.toml'
- 'uv.lock'
- '.python-version'
- 'migrations/**'
- 'sdks/typescript/**'
- '.github/workflows/unittest.yml'
@ -85,7 +88,7 @@ jobs:
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
python-version-file: ".python-version"
- name: Install bun
uses: oven-sh/setup-bun@v2

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

@ -1 +1 @@
3.11
3.13

View File

@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [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
@ -176,7 +214,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)
@ -194,7 +232,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

@ -187,6 +187,9 @@ The Dreamer is an orchestrated multi-specialist system that runs during schedule
- **LLM subsystem** (`src/llm/`): provider-agnostic `honcho_llm_call()`. Backends in `src/llm/backends/` (`anthropic.py`, `gemini.py`, `openai.py`). Includes prompt caching (`caching.py`), structured output (`structured_output.py`), tool loop (`tool_loop.py`), history adapters for cross-provider message formats, and a model registry. Per-retry provider selection is pinned via an `AttemptPlan` so stream-final retries don't bounce back to primary after the tool loop has settled on fallback.
- **Per-agent model config**: each agent has its own `MODEL_CONFIG` in `src/config.py` with fallback chains (see `ConfiguredModelSettings`, `FallbackModelSettings`).
- **Telemetry**: cloudevents in `src/telemetry/events/` cover API routes, dialectic, dream, deletion, reconciliation, representation, and per-call LLM accounting (`llm.py` — `LLMCallCompletedEvent` fires once per provider hit with full cost-attribution context). High-volume events are sampled deterministically per `run_id` via `TelemetrySettings.HIGH_VOLUME_SAMPLE_RATE`.
- **Prometheus metrics** (`src/telemetry/prometheus/`): every metric carries a `namespace` label and every recorder is fail-soft (a metrics error never propagates into a request or a worker loop). Counter children with a *bounded* label domain are zero-initialized per process at startup — `initialize_bounded_metrics(instance_type=...)`, called from the `src/main.py` lifespan (`api`) and `src/deriver/__main__.py` (`deriver`) — so an absent series means a broken scrape rather than "nothing happened". Two consequences worth knowing before touching telemetry:
- **Adding a `BaseEvent` subclass requires adding its `_event_type` to `ALL_EVENT_TYPES`** in `src/telemetry/events/__init__.py` (and to `HIGH_VOLUME_EVENT_TYPES` if `_volume_class == "high_volume"`). Enforced by the drift guards in `tests/telemetry/test_metric_zero_init.py`, which assert set-equality against the discovered subclasses.
- **A service-wide, non-additive gauge must be refreshed by every replica on its own timer**, and aggregated with `max()`/`avg()`, never `sum()`. `message_embeddings_pending` is the example: it reports a DB-global count, so it is driven from `ReconcilerScheduler._scheduler_loop` (runs on all replicas) rather than from the work-unit-deduped reconciliation cycle — otherwise, combined with the zero-init, every replica that never won the work unit would export a confident permanent `0`.
### Project Structure

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

@ -10,6 +10,10 @@
{
"source": "/v3/guides/integrations/claudecode",
"destination": "/v3/guides/integrations/claude-code"
},
{
"source": "/v3/documentation/features/advanced/representation-scopes",
"destination": "/v3/documentation/features/advanced/directional-representations"
}
],
"colors": {
@ -24,7 +28,7 @@
"navigation": {
"versions": [
{
"version": "v3.0.12",
"version": "v3.1.0",
"api": {
"openapi": ["v3/openapi.json"]
},
@ -62,7 +66,8 @@
"v3/documentation/features/advanced/reasoning-configuration",
"v3/documentation/features/advanced/summarizer",
"v3/documentation/features/advanced/peer-card",
"v3/documentation/features/advanced/representation-scopes",
"v3/documentation/features/advanced/directional-representations",
"v3/documentation/features/advanced/scopes",
"v3/documentation/features/advanced/dreaming",
"v3/documentation/features/advanced/queue-status",
"v3/documentation/features/advanced/webhooks",
@ -208,6 +213,18 @@
"v3/api-reference/endpoint/sessions/search-session"
]
},
{
"group": "scopes",
"pages": [
"v3/api-reference/endpoint/scopes/get-or-create-scope",
"v3/api-reference/endpoint/scopes/get-scopes",
"v3/api-reference/endpoint/scopes/get-scope",
"v3/api-reference/endpoint/scopes/add-sessions-to-scope",
"v3/api-reference/endpoint/scopes/get-scope-sessions",
"v3/api-reference/endpoint/scopes/remove-session-from-scope",
"v3/api-reference/endpoint/scopes/get-scope-status"
]
},
{
"group": "messages",
"pages": [
@ -594,8 +611,8 @@
}
},
"integrations": {
"posthog": {
"apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk"
"gtm": {
"tagId": "GTM-NSPT9PJF"
}
}
}

69
docs/posthog-consent.js Normal file
View File

@ -0,0 +1,69 @@
// Loads PostHog only when the CookieConsent cookie grants Statistics; the
// cookie is host-scoped, so a landing-page answer covers the docs.
;(function () {
var KEY = 'phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk'
var loaded = false
function granted() {
var m = document.cookie.match(/(?:^|;\s*)CookieConsent=([^;]*)/)
if (!m) return false
var v = decodeURIComponent(m[1])
// "-1" is Cookiebot's consent-not-required marker.
return v === '-1' || /statistics\s*:\s*true/.test(v)
}
function loadPosthog() {
if (loaded) return
loaded = true
var s = document.createElement('script')
s.src = 'https://us-assets.i.posthog.com/static/array.js'
s.async = true
s.onerror = function () {
loaded = false
}
s.onload = function () {
// Consent withdrawn while array.js was downloading: skip init, allow a retry on re-grant.
if (!granted()) {
loaded = false
return
}
window.posthog.init(KEY, {
api_host: 'https://us.i.posthog.com',
ui_host: 'https://us.posthog.com',
cross_subdomain_cookie: true,
person_profiles: 'identified_only',
capture_pageview: 'history_change',
})
}
document.head.appendChild(s)
}
function sync() {
if (granted()) {
if (!loaded) {
loadPosthog()
} else if (
window.posthog &&
window.posthog.has_opted_out_capturing &&
window.posthog.has_opted_out_capturing()
) {
window.posthog.opt_in_capturing()
}
return
}
// Withdrawal mid-session: an already running instance must stop.
if (loaded && window.posthog && window.posthog.opt_out_capturing) {
window.posthog.opt_out_capturing()
}
}
sync()
var events = [
'CookiebotOnConsentReady',
'CookiebotOnAccept',
'CookiebotOnDecline',
]
for (var i = 0; i < events.length; i++) {
window.addEventListener(events[i], sync)
}
})()

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

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list
---

View File

@ -0,0 +1,3 @@
---
openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}/status
---

View File

@ -0,0 +1,3 @@
---
openapi: get /v3/workspaces/{workspace_id}/scopes/{scope_id}
---

View File

@ -0,0 +1,3 @@
---
openapi: post /v3/workspaces/{workspace_id}/scopes/list
---

View File

@ -0,0 +1,3 @@
---
openapi: delete /v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}
---

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

@ -109,7 +109,6 @@ Messages are stored but no observations, summaries, or representations are being
```bash
DERIVER_WORKERS=4
```
5. **Representation Batching** — By default the deriver buffers representation work until a work unit has accumulated enough tokens, set via `DERIVER_REPRESENTATION_BATCH_WORK_UNIT_TARGET_TOKENS` (`0` disables the accumulation gate). A separate setting, `DERIVER_REPRESENTATION_BATCH_TARGET_INPUT_TOKENS`, caps the conversation window fed to each deriver LLM call when draining a claimed work unit. Sub-threshold tails become eligible after `DERIVER_REPRESENTATION_BATCH_MAX_AGE_SECONDS` (default 1800 seconds), so quiet sessions eventually flush without disabling batching globally. Set the age to `0` for legacy behavior where sub-threshold tails wait indefinitely. See [token batching](/v3/documentation/core-concepts/reasoning#token-batching) for more details
## Alternative Provider Issues
@ -316,11 +315,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

@ -34,7 +34,7 @@ Honcho has a hierarchical data model centered around the entities below.
Workspaces are the top-level containers in Honcho. They provide complete isolation between different applications or environments, essentially serving as a namespace to keep different workloads separate. You might use separate workspaces for development, staging, and production environments, or to isolate different product lines. They also enable multi-tenant SaaS applications where each customer gets their own isolated workspace with complete data separation.
Authentication is scoped to the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace.
Authentication is issued at the workspace level, and configuration settings can be applied workspace-wide to control behavior across all peers and sessions within that workspace.
---
@ -50,12 +50,14 @@ You can use peers for any entity that persists over time--individual users in ch
### <Icon icon="message" /> Sessions
Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you scope context and memory to specific interactions while still maintaining longer-term peer representations that span sessions.
Sessions represent interaction threads or contexts between peers. A session can involve multiple peers and provides temporal boundaries for when a set of interactions starts and ends. This lets you confine context and memory to specific interactions while still maintaining longer-term peer representations that span sessions.
Use sessions to scope things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation.
Use sessions for things like support tickets, meeting transcripts, learning sessions, or conversations. You can also use single-peer sessions as a way to import external data--create a session with just one peer and structure emails, documents, or files as messages to enrich that peer's representation.
Session-level configuration gives you fine-grained control over perspective-taking behavior. You can configure whether a peer should form representations of other peers in the session, and whether other peers should form representations of them.
Sessions are also the unit of visibility: when one peer's history spans contexts that shouldn't inform each other, you can group sessions into named [scopes](/v3/documentation/features/advanced/scopes) that bound recall to just those sessions.
---
### <Icon icon="envelope" /> Messages
@ -84,7 +86,7 @@ Honcho runs as two cooperating processes: an **API server** that handles request
**Write path (synchronous).** A message is stored and a reasoning task is enqueued in the same request; the API returns immediately. Nothing about the reasoning that follows blocks the caller.
**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks in small batches. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message (well, per-batch) rather than on a schedule.
**Deriver + Summarizer (async, per-message).** The worker picks up queued tasks. The Deriver reads new messages and extracts conclusions about the peer--explicit statements and direct deductions. In parallel, the Summarizer periodically rolls up recent messages into short- and long-form session summaries. Both run per-message rather than on a schedule.
**Dreamer (periodic).** On a schedule (or triggered on demand), the Dreamer revisits existing conclusions to consolidate and deepen them: removing redundant or stale ones, drawing inductive conclusions across patterns that span multiple messages, and updating peer cards--compact biographical summaries of a peer. This is where memory gets richer over time, not just larger.

View File

@ -12,22 +12,26 @@ Ready to add Honcho to your codebase? The **`/honcho-integration` skill** applie
## Quick Reference
**Workspaces isolate, peers persist, and sessions scope the active context.**
**Workspaces isolate, peers persist, and sessions bound the active context.**
| Decision | Recommendation |
|----------|---------------|
| How many workspaces? | One workspace per application, tool, tenant, or collaboration boundary. Split workspaces only when you need hard isolation between products, customers, environments, or agents. |
| When should agents share a workspace? | When agents collaborate over the same product, project, team, user, customer, or game state. Separate them when they should not see or influence each other's memory. |
| Who should be a peer? | Any persistent participant whose messages should be attributed or reasoned about: users, agents, assistants, NPCs, students, or customers. Use one peer for the same entity across sessions and platforms. |
| How should I scope sessions? | Scope sessions to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. |
| How should I divide sessions? | Match each session to the active interaction: per-conversation, per-channel, per-task run, per-project, per-import, or other bounded context. Reuse a session when local context should keep accumulating. |
| How does cross-session reasoning work? | Session memory stays local to one session. Peer representations accumulate across every session where the peer is included, and `session.context()` becomes cross-session when you include a peer target. |
| Should I set `observe_me: false`? | Yes, for deterministic peers Honcho does not need to model, like bots or tool agents. Still save their messages so other peers have session context. Keep it enabled for users and evolving agents. |
| Do I need `observe_others`? | Only when a peer needs its own perspective on another participant, such as in games, multi-agent systems, or parent/subagent workflows. |
| When do I need a scope? | When one peer's history spans contexts that must not leak into each other's recall — but you still want one workspace and one unified peer. Group the confidential sessions into a [scope](/v3/documentation/features/advanced/scopes) and pass it at query time. |
| Perspectives or scopes? | `observe_others` gives a *participant* its own view of another peer. A scope bounds recall to *where things were said*, for a reader that isn't a participant. If the reader is in the session, use perspectives; if you're fencing off a set of sessions, use a scope. |
## Workspace Design
A workspace is a hard isolation boundary. **Default to one workspace per application,** and split only at a real privacy, compliance, or product boundary (e.g. per-tenant SaaS, or a tool that needs intentionally isolated memory). Agents that collaborate over the same product, user, or game state belong in the *same* workspace so each can retrieve what the others produced.
If what you actually need is "this part of a peer's history shouldn't inform that assistant," don't split the workspace — that severs the peer's identity too. Use a [scope](/v3/documentation/features/advanced/scopes) instead: the peer stays whole, and recall through the scope sees only its member sessions.
Honcho plugins default to one workspace *per host* (`hermes`, `claude_code`, `cursor`, `opencode`). To unify memory across them, point each at the same workspace — see [Unified Memory Setup](/v3/guides/recipes/unified-memory-setup).
<Info>
@ -48,11 +52,11 @@ For unified context across Honcho plugins, set the same user peer ID (`peerName`
## Session Design
Sessions define the temporal boundaries of an interaction. How you scope them affects how summaries are generated, how context is retrieved, and when reasoning fires.
Sessions define the temporal boundaries of an interaction. Where you draw those boundaries affects how summaries are generated and how context is retrieved.
**Common session patterns**
| Pattern | Session scoped to | Example |
| Pattern | Session covers | Example |
|---------|-------------------|---------|
| Per-conversation | Each new chat thread | ChatGPT or Claude Code style UI where each thread is a session |
| Per-channel | A persistent channel or room | Discord channel, Slack thread |
@ -62,10 +66,6 @@ Sessions define the temporal boundaries of an interaction. How you scope them af
Create a **new** session when context resets (new conversation, new day, new topic); **reuse** one when context should keep accumulating (ongoing channel, persistent thread).
<Warning>
**Don't scope sessions too thin.** Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*, with a default age-based flush for quiet tails ([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Low-volume or trickle inputs should still append to one ongoing session rather than fragment across many, so reasoning runs with useful context instead of many small delayed batches.
</Warning>
**How cross-session reasoning works**
- **Session memory** is local to an interaction — summaries and recent-message context describe only what happened there.
@ -75,14 +75,33 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session
---
## Choosing an Isolation Boundary
Honcho gives you three boundaries at different strengths. Pick the weakest one that solves your problem:
| Boundary | Strength | Use when |
|----------|----------|----------|
| **Workspace** | Hard isolation — nothing crosses, including the peer itself | Different products, tenants, or environments |
| **[Scope](/v3/documentation/features/advanced/scopes)** | Recall boundary — one peer, but queries through the scope see only its sessions | One peer's contexts must not leak into each other (clinical vs. billing, per-reseller support) |
| **Session allowlist** (`sessions=[...]`) | Ad-hoc recall restriction, decided per request | The session set varies per query, or you need a quick boundary without provisioning anything |
Two things scopes are **not**:
- **Not authorization.** A workspace key reads any session, scoped or not. A scope constrains queries that name it; it doesn't protect data from queries that don't.
- **Not topic filtering.** Scopes bound recall by *where something was said*, not what it's about. A therapy detail mentioned in a billing session lands in the billing scope. If you might ever need a scope boundary, align your session boundaries with your confidentiality boundaries from the start — the session is the unit scopes can enforce.
---
## Common Mistakes
- **Splitting one identity across peer IDs** -- If the same user is `alice`, `alice-discord`, and `alice-cursor`, Honcho builds separate representations. Use one stable peer ID when you want unified memory.
- **Too many tiny sessions** -- Summaries and recent messages are session-scoped, and reasoning only fires past ~1,000 tokens per session. Splitting a continuous conversation across many sessions fragments local context and can stall reasoning. Reuse a session when context should flow continuously.
- **Too many tiny sessions** -- Summaries and recent messages are local to one session. Splitting a continuous conversation across many sessions fragments that local context. Reuse a session when context should flow continuously.
- **Separating agents that should collaborate** -- If agents need shared product, customer, or team context, put them in the same workspace. Separate workspaces are hard isolation boundaries.
- **Leaving `observe_me` on for assistants** -- Wastes reasoning compute on a peer you control. Deterministic behavior doesn't need to be modeled.
- **Turning on `observe_others` everywhere** -- Directional representations are powerful, but they add complexity. Use them when peers need distinct perspectives, not just because a session has multiple peers.
- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are session-scoped. It becomes cross-session only through adding a peer_target which includes the peer representation.
- **A scope per reader** -- Scopes should map to real confidentiality boundaries, not to consumers. If every assistant gets its own scope, you've rebuilt workspace fragmentation inside one workspace, and each projection reasons over a thin slice. Fewer, boundary-shaped scopes; many readers can share one.
- **Treating scopes as access control** -- A scope bounds *recall*, not *access*. Enforce who may query what in your application layer; use scopes to keep the answers themselves from drawing on out-of-bounds sessions.
- **Forgetting `peer_target` on session context** -- `session.context()` defaults to the active session's summary and recent messages, which are local to that session. It becomes cross-session only through adding a peer_target which includes the peer representation.
- **Blocking on processing** -- Messages are processed asynchronously in the background. Don't poll or wait for reasoning to complete before continuing your application flow.
## Next Steps
@ -94,6 +113,9 @@ So you can start a session fresh or pull in a peer's long-term memory. [`session
<Card title="Get Context" icon="messages" href="/v3/documentation/features/get-context">
Retrieve formatted context from sessions for your LLM
</Card>
<Card title="Scopes" icon="shield-halved" href="/v3/documentation/features/advanced/scopes">
Bound recall to named sets of sessions
</Card>
<Card title="Chat Endpoint" icon="comments" href="/v3/documentation/features/chat">
Query Honcho about your peers with natural language
</Card>

View File

@ -66,21 +66,11 @@ The reasoning outputs--conclusions, summaries, peer cards--are stored as part of
The diagram above shows how agents write messages to Honcho, which triggers reasoning that updates peer representations. Agents can then query representations to get additional context for their next response.
### Token Batching
Rather than running inference on every individual message, Honcho accumulates messages in the queue and processes them as a batch once the total token count of pending messages for a given peer representation crosses a threshold--roughly **1,000 tokens** at the current batch size. This keeps ingestion costs down, since Honcho charges based on reasoning passes, and ensures each pass has a meaningful amount of context to work with. At ~1,000 tokens the batch comfortably fits in the context window of any modern LLM, so no content is lost.
If a user sends several short messages in a row (e.g., "yes", "ok", "sounds good"), those messages sit in the queue until enough content has accumulated. Once the threshold is met, the full batch is processed together in a single reasoning call.
<Note>
This batching only applies to **representation** tasks (conclusion extraction). Summary and dream tasks have their own scheduling logic and are not subject to the token threshold.
</Note>
## Balances & Design Choices
Off-the-shelf LLMs can perform formal logical reasoning, but they aren't optimized for it. Honcho uses custom models trained specifically for logical rigor (following formal reasoning rules rather than plausible-sounding text), structured output (consistent JSON schema with premises and conclusions), and efficiency (smaller, faster models tuned for this specific task). This allows Honcho to reason more reliably and at lower cost than general-purpose frontier LLMs.
The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, scaffolded conclusions are more token-efficient than raw conversation history, and we batch where appropriate to optimize update frequency.
The approach balances quality with practical constraints. Custom models are smaller and cheaper to run, and scaffolded conclusions are more token-efficient than raw conversation history.
Honcho's reasoning capabilities are actively being improved. Current areas of development include enhanced inductive and abductive reasoning, multi-hop and temporal reasoning, and expanded file types and modalities. The system is designed to be extensible--new reasoning capabilities can be added without breaking existing functionality.

View File

@ -1,6 +1,6 @@
---
title: 'Representation Scopes'
description: 'Advanced configuration and querying for representations'
title: 'Directional Representations'
description: 'How peers build and query representations of other peers'
icon: 'circle'
---
@ -214,7 +214,7 @@ Most applications don't need directional representations. Start with the default
Under the hood, Honcho stores representations as (observer, observed) pairs in internal collections:
- **Collection**: A unique (observer, observed, workspace) tuple containing documents
- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with session scoping
- **Documents**: Individual conclusions and artifacts (deductive, inductive, abductive conclusions, summaries, peer cards) with per-session filtering
When you retrieve with `target`, Honcho fetches documents from the specific (observer, observed) collection. When you retrieve without `target`, it fetches from the (peer, peer) collection—the peer's self-representation.
@ -225,7 +225,7 @@ This architecture enables:
## Semantic Search Parameters
Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to scope to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to scope to a set of sessions:
Both `representation()` and `chat()` support semantic filtering to retrieve a subset of relevant conclusions. You can optionally filter by session — pass `session` to restrict to a single session, or use the REST-only [session allowlist](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions) to restrict to a set of sessions:
| Parameter | Type | Description |
|-----------|------|-------------|
@ -265,7 +265,7 @@ Directional representations update automatically through the reasoning pipeline
2. The message sender has `observe_me=true` (or session-level equivalent)
3. Other peers in the session have `observe_others=true`
The pipeline respects scoping—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant.
The pipeline respects these boundaries—Honcho's representations reason over messages across all sessions, while directional representations only reason over messages from sessions where the observer was an active participant.
### Peer Join Order Matters

View File

@ -12,7 +12,8 @@ Advanced features give you fine-grained control over Honcho's behavior and imple
- [Configuration](/v3/documentation/features/advanced/reasoning-configuration) - Configure reasoning models and behavior
- [Summarizer](/v3/documentation/features/advanced/summarizer) - Automatic session summarization
- [Peer Card](/v3/documentation/features/advanced/peer-card) - Quick-reference profile of stable biographical facts about a peer
- [Representation Scopes](/v3/documentation/features/advanced/representation-scopes) - Directional representations for multi-peer scenarios
- [Directional Representations](/v3/documentation/features/advanced/directional-representations) - How peers build separate representations of each other
- [Scopes](/v3/documentation/features/advanced/scopes) - Named sets of sessions that act as visibility boundaries for recall
- [Dreaming](/v3/documentation/features/advanced/dreaming) - Autonomous memory consolidation and self-improvement
- [Queue Status](/v3/documentation/features/advanced/queue-status) - Monitor background processing and reasoning tasks

View File

@ -79,7 +79,7 @@ console.log(card);
## Directional Peer Cards
Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/representation-scopes). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes.
Peer cards follow the same observer-observed model as [representations](/v3/documentation/features/advanced/directional-representations). When `observe_others` is enabled, a peer can have a **different** card for each peer it observes.
For example, if Alice and Bob are in a session together and Alice has `observe_others: true`, Alice will build her own peer card for Bob--separate from Honcho's peer card for Bob. You can read and write these directional cards using the `target` parameter.

View File

@ -8,9 +8,9 @@ Whenever messages are stored in Honcho, background processes kick off to [reason
Reasoning is an asynchronous process and will not immediately
generate insights for the latest message you've sent. This is
by design: we want to reason efficiently over batches of messages
rather than assessing each message in a vacuum. Honcho provides
several utilities to check the status of the queue.
by design: Honcho reasons in the background rather than on the
write path. Honcho provides several utilities to check the status
of the queue.
<CodeGroup>
```python Python
@ -95,7 +95,7 @@ not the total number of items ever processed.
</Note>
The `queue_status` method can take additional
parameters to scope the status to a specific work unit:
parameters to filter the status by a matching observer, sender, or session:
<CodeGroup>
```python Python

View File

@ -157,7 +157,7 @@ You may therefore disable observation of a peer by setting the `observe_me` flag
If the peer has a session-level configuration, it will override this configuration. If the flag is not set, or is set to `true`, the peer will be observed.
<Info>
For session-level observation controls and local representations (where peers build separate models of each other), see [Representation Scopes](/v3/documentation/features/advanced/representation-scopes).
For session-level observation controls and local representations (where peers build separate models of each other), see [Directional Representations](/v3/documentation/features/advanced/directional-representations).
</Info>
<CodeGroup>

View File

@ -0,0 +1,355 @@
---
title: 'Scopes'
description: 'Named sets of sessions that act as visibility boundaries for recall'
icon: 'shield-halved'
---
A **scope** is a named set of sessions that acts as a visibility boundary. Recall
performed through a scope sees only what happened in that scope's sessions,
while the peer keeps its single unified representation of everything it has ever
participated in.
Use scopes when one peer's history spans contexts that must not leak into each
other — a therapy app where the clinical sessions must not inform the billing
assistant, a support product where a reseller's agent may only answer from its
own tickets, a multi-tenant deployment where one human works across tenants.
## Projection, Not Partition
The peer keeps one representation. A scope is a **projection** of it: a view
built only from evidence in the member sessions.
```mermaid
graph TB
P[Peer: user-123<br/>one unified representation]
P --> S1[session: therapy-1]
P --> S2[session: therapy-2]
P --> S3[session: billing-1]
P --> S4[session: onboarding-1]
SC1[scope: therapy] -.->|projects| S1
SC1 -.->|projects| S2
SC2[scope: billing] -.->|projects| S3
style P fill:#B6DBFF,stroke:#333,color:#000
style S1 fill:#B6DBFF,stroke:#333,color:#000
style S2 fill:#B6DBFF,stroke:#333,color:#000
style S3 fill:#B6DBFF,stroke:#333,color:#000
style S4 fill:#B6DBFF,stroke:#333,color:#000
style SC1 fill:#FFE0B2,stroke:#333,color:#000
style SC2 fill:#FFE0B2,stroke:#333,color:#000
```
- **Sessions can belong to more than one scope.** Membership is many-to-many.
- **Sessions can belong to no scope.** `onboarding-1` above is reachable
by an unscoped request and by nothing else.
- **An unscoped request still sees everything.** A scope constrains the requests
that name it; it does not hide the sessions from requests that don't.
<Warning>
Scopes are a recall boundary, not an authorization boundary. Who may call the
API is still governed by workspace, session, and peer keys.
</Warning>
## The Two Arms
There are two ways to confine recall, and they behave differently. Picking the
wrong one is the most common mistake with this feature.
| | `scope="therapy"` (named scope) | `sessions=[...]` / `scope=["a","b"]` (allowlist) |
|---|---|---|
| **Mechanism** | Reads the scope's own representation of the peer | Restricts the peer's own representation to a set of sessions |
| **Conclusions** | All levels — `explicit`, plus `deductive` / `inductive` reasoned **within** the scope | `explicit` only |
| **Reasoning chains** | Available | Unavailable |
| **Setup required** | Yes — create the scope, add sessions, wait for backfill | None — pass session IDs ad hoc |
| **Accepts** | One scope name | A list of up to 100 scope names, or up to 1,000 session IDs |
### Named scope: depth
Passing a **single** scope name swaps the observer. Recall runs against the
scope's own view of the target peer, which the deriver and dreamer have been
building from the scope's member sessions all along. That view contains
higher-order inferences — but only ones reasoned from evidence inside the scope.
```python
answer = user.chat("What is stressing them out?", scope="therapy")
```
This is the arm you want for a durable, meaningful boundary.
### Allowlist: breadth
Passing a **list** of scopes, or a bare list of session IDs, keeps the peer as
the observer and restricts recall to the union of those sessions. Because a
dream-derived conclusion is synthesized across sessions, it cannot be attributed
to any one of them — so this arm recalls `explicit` conclusions only, and answers
from directly-stated facts rather than inference.
```python
answer = user.chat("What did they say about billing?", sessions=[s1, s2])
answer = user.chat("What did they say?", scope=["therapy", "intake"])
```
Reach for this when the set of sessions is decided per-request, or when you want
a quick boundary without provisioning a scope. See
[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions)
for the full allowlist rules.
<Info>
A list of scopes is the allowlist arm, not "several named scopes at once". It
gives you the union of their *sessions*, at explicit-only depth — it does not
give you the union of their reasoned views. If you need depth, query one scope.
</Info>
## Creating a Scope and Managing Membership
<CodeGroup>
```python Python
from honcho import Honcho
honcho = Honcho(workspace_id="my-app")
# Get or create — idempotent; passing metadata updates the existing scope
therapy = honcho.scope("therapy")
# Add existing sessions (max 100 per call)
therapy.add_sessions(["therapy-session-1", "therapy-session-2"])
# Or attach at session creation — the scope is created if it doesn't exist
session = honcho.session("therapy-session-3", scopes=["therapy"])
# Inspect
for s in therapy.sessions():
print(s.id)
therapy.remove_session("therapy-session-1")
for scope in honcho.scopes():
print(scope.id, scope.metadata)
```
```typescript TypeScript
import { Honcho } from "@honcho-ai/sdk";
const honcho = new Honcho({ workspaceId: "my-app" });
// Get or create — idempotent; passing metadata updates the existing scope
const therapy = await honcho.scope("therapy");
// Add existing sessions (max 100 per call)
await therapy.addSessions(["therapy-session-1", "therapy-session-2"]);
// Or attach at session creation — the scope is created if it doesn't exist
const session = await honcho.session("therapy-session-3", {
scopes: ["therapy"],
});
// Inspect
for await (const s of await therapy.sessions()) {
console.log(s.id);
}
await therapy.removeSession("therapy-session-1");
for await (const scope of await honcho.scopes()) {
console.log(scope.id, scope.metadata);
}
```
```bash REST
# Get or create (201 created / 200 existing)
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes" \
-H "Authorization: Bearer $HONCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "therapy"}'
# Add sessions
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions" \
-H "Authorization: Bearer $HONCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"session_ids": ["therapy-session-1", "therapy-session-2"]}'
# List membership
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/list" \
-H "Authorization: Bearer $HONCHO_API_KEY"
# Remove one session
curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/therapy-session-1" \
-H "Authorization: Bearer $HONCHO_API_KEY"
```
</CodeGroup>
Scope IDs are unprefixed, must match `^[a-zA-Z0-9_-]+$`, and are at most 506
characters. Get-or-create is idempotent: if the scope already exists, the same
call returns it, and any `metadata` you pass is written onto it.
<Note>
Every scopes route — and every read that passes `scope` — requires a
**workspace-level or admin key**. A scope's membership can exceed any single
peer's own session membership, so peer- and session-scoped keys are rejected
with `401`.
</Note>
## Membership Changes Copy, They Don't Re-Derive
A session added to a scope while empty needs nothing special: messages sent
after the change flow into the scope through the normal deriver fan-out.
A session that **already has messages** is handled retroactively by a background
job rather than by re-running the LLM over its history: adding it copies the
session's existing `explicit` conclusions into the scope, and removing it
retracts that session's contributions — including conclusions derived from them.
Copying rather than re-deriving is why membership changes are cheap and
deterministic — and why they are also **asynchronous**. It also means a freshly
backfilled scope starts at explicit depth and accrues deeper reasoning through
subsequent dreams.
Poll `status()` to tell "the scope hasn't caught up yet" apart from "the scope
has caught up and there is genuinely nothing to recall":
<CodeGroup>
```python Python
therapy.add_sessions(["old-session-with-history"])
status = therapy.status()
# {"old-session-with-history": {"state": "pending", "updated_at": "..."}}
# → later: {"state": "completed", "docs_copied": 42, "updated_at": "..."}
```
```typescript TypeScript
await therapy.addSessions(["old-session-with-history"]);
const status = await therapy.status();
// { "old-session-with-history": { state: "pending", updatedAt: "..." } }
```
```bash REST
curl "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/status" \
-H "Authorization: Bearer $HONCHO_API_KEY"
```
</CodeGroup>
`state` is `pending`, `completed`, or `failed`; `docs_copied` appears once a
backfill completes. Only sessions that have had a backfill enqueued appear, so an
empty result means none have — not that the scope is empty.
## Reading Through a Scope
`scope` is accepted on these surfaces:
| Surface | Accepts | Notes |
|---------|---------|-------|
| [`peer.chat()`](/v3/documentation/features/chat) | one scope or a list | Confines both conclusion recall and the messages the agent reads |
| `peer.representation()` | one scope or a list | Confines conclusion recall |
| [`session.context()`](/v3/documentation/features/get-context) | one scope only | Perspective source for `peer_target`'s representation and card. Requires `peer_target`; mutually exclusive with `peer_perspective` |
| `honcho.search()` | one scope only | Restricts message search to the scope's member sessions |
| `honcho.chat()` | one scope or a list | Always the allowlist arm — even a single name. There is no observer to swap |
<CodeGroup>
```python Python
# Chat — answered only from the therapy sessions
answer = user.chat("What is stressing them out?", scope="therapy")
# Representation
rep = user.representation(scope="therapy")
# Session context, using the scope as the perspective source
ctx = session.context(peer_target="user-123", scope="therapy")
# Message search, restricted to the scope's sessions
messages = honcho.search("insomnia", scope="therapy")
```
```typescript TypeScript
// Chat — answered only from the therapy sessions
const answer = await user.chat("What is stressing them out?", {
scope: "therapy",
});
// Representation
const rep = await user.representation({ scope: "therapy" });
// Session context, using the scope as the perspective source
const ctx = await session.context({
peerTarget: "user-123",
scope: "therapy",
});
// Message search, restricted to the scope's sessions
const messages = await honcho.search("insomnia", { scope: "therapy" });
```
```bash REST
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \
-H "Authorization: Bearer $HONCHO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "What is stressing them out?", "scope": "therapy"}'
```
</CodeGroup>
### Rules
`scope` is mutually exclusive with `filters`, `sessions`, and `session` /
`session_id` — and on session context, with `peer_perspective` (where it also
requires `peer_target`). Like the session allowlist, it **fails closed**: a
contradiction is rejected with a `422` rather than silently widened, a scope
with no member sessions recalls nothing, and an empty list (`scope=[]`) is
rejected rather than treated as "no boundary". Per-surface caps and error
shapes are in the [API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope).
## Provenance, Not Topic
A scope is defined by **where a fact was said**, not what it is about.
If a user mentions a therapy detail in a billing session, that conclusion is
formed from the billing session and lands in the `billing` scope. Querying
`scope="therapy"` will not find it, and querying `scope="billing"` will.
<Warning>
Scopes give you provenance-based privacy, not topic-based privacy. If you need
"no clinical content in the billing assistant's answers" regardless of where it
was said, that is content classification and has to be enforced above Honcho —
by controlling what reaches which session in the first place, or by filtering
the answer.
</Warning>
Design accordingly: keep the session boundary aligned with the confidentiality
boundary you actually care about, since that session boundary is the one scopes
can enforce.
## Guardrails
A few behaviors follow from how scopes are built:
- **The `scope.` prefix is reserved.** Creating a peer, or adding a peer to a
session, with a `scope.`-prefixed name is rejected.
- **List scopes through the scopes surface.** `honcho.scopes()` /
`POST /scopes/list` returns unprefixed ids. Peer listings hide scopes by
default; `kind="scope"` on `POST /peers/list` returns the backing peers named
`scope.<id>`, and `kind="all"` includes both regular peers and those backing
peers.
- **A scope can't be observed.** No representation is formed *of* a scope, so a
scope is rejected in any `target` / observed position, including as a dream
target.
- **Membership is managed only through the scopes surface.** The session
add-peers, set-peers, and remove-peers routes reject scope names and point you
at `/scopes/{scope_id}/sessions` or the `scopes` field on session create.
If you want the exact mechanics for scopes, read: [`src/routers/scopes.py`](https://github.com/plastic-labs/honcho/blob/main/src/routers/scopes.py),
[`src/crud/scope.py`](https://github.com/plastic-labs/honcho/blob/main/src/crud/scope.py),
and [`src/deriver/scope_backfill.py`](https://github.com/plastic-labs/honcho/blob/main/src/deriver/scope_backfill.py).
## Limits
| Limit | Value |
|-------|-------|
| Scope ID length | 506 characters |
| Scope ID charset | `^[a-zA-Z0-9_-]+$` |
| Sessions per membership call | 100 |
| Scopes in one `scope` read option | 100 |
| Scopes on session create | 100 |
| Sessions in a resolved allowlist | 1,000 |
Full request and response shapes are in the
[API reference](/v3/api-reference/endpoint/scopes/get-or-create-scope).

View File

@ -49,6 +49,20 @@ import { Honcho } from "@honcho-ai/sdk";
```
</CodeGroup>
Pass `scope` on workspace search to restrict matches to that
[scope](/v3/documentation/features/advanced/scopes)'s member sessions. A scope
with no members returns nothing.
<CodeGroup>
```python Python
results = honcho.search("budget planning", scope="therapy")
```
```typescript TypeScript
const results = await honcho.search("budget planning", { scope: "therapy" });
```
</CodeGroup>
### Session Search
Search within a specific session's conversation history:

View File

@ -727,7 +727,7 @@ messages = session.messages(filters={
### Filtering Conclusions
Conclusions are scoped to an observer/observed peer pair (accessed via
Conclusions belong to an observer/observed peer pair (accessed via
`peer.conclusions` for self-conclusions or `peer.conclusions_of(target)` for
conclusions about another peer). The observer and observed are filled in
automatically by the scope, so the `filters` you pass add to them.
@ -843,7 +843,7 @@ a **session allowlist**, restricting what the request can recall to the sessions
you name — conclusions on both endpoints, and on chat the messages the agent
reads as well.
This is how you scope recall to more than one session. The `session_id`
This is how you restrict recall to more than one session. The `session_id`
parameter pins a request to exactly one session; an allowlist accepts a set.
Only the `session_id` key is supported here, in three shapes:
@ -875,10 +875,34 @@ curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/representation" \
```
</CodeGroup>
Both SDKs expose this as a `sessions` option, which goes on the wire as the
`filters` body above:
<CodeGroup>
```python Python
answer = user.chat("What did the user ask about billing?",
sessions=["support-chat-1", "support-chat-2"])
rep = user.representation(sessions=["support-chat-1", "support-chat-2"])
```
```typescript TypeScript
const answer = await user.chat("What did the user ask about billing?", {
sessions: ["support-chat-1", "support-chat-2"],
});
const rep = await user.representation({
sessions: ["support-chat-1", "support-chat-2"],
});
```
</CodeGroup>
<Note>
The session allowlist is REST-only today. The SDKs cover the single-session case
with `session`, but do not yet expose the allowlist — call the endpoint directly
when you need a set of sessions.
If the same set of sessions is a boundary you reuse, name it: a
[scope](/v3/documentation/features/advanced/scopes) is a persistent version of
this allowlist, and querying a single scope recalls at full depth rather than
`explicit`-only. `sessions` is the right tool when the set is decided
per-request.
</Note>
### Rules
@ -907,7 +931,7 @@ can only narrow.
### What Changes Under an Allowlist
Scoping recall by session narrows what the reasoning agent can draw on:
Restricting recall by session narrows what the reasoning agent can draw on:
- **Only `explicit` conclusions are recalled.** Dream-derived conclusions
(`deductive`, `inductive`) are synthesized across sessions, so they can't be

View File

@ -110,11 +110,17 @@ const answer = await peer.chat("What did the user ask about?", { session: sessio
```
</CodeGroup>
To scope a request to a *set* of sessions, use the session allowlist — a
To restrict a request to a *set* of sessions, use the session allowlist — a
constrained `filters` body on the endpoint. See
[Scoping Recall to Sessions](/v3/documentation/features/advanced/using-filters#scoping-recall-to-sessions)
for the accepted shapes and for what an allowlist changes about the answer.
Pass `scope="therapy"` to answer from that [scope](/v3/documentation/features/advanced/scopes)'s
own representation of the peer. A list (`scope=["therapy", "intake"]`) is an
allowlist of those scopes' sessions, not named-scope depth.
`honcho.chat(scope=)` is always the allowlist arm, even with one name. Details
are on the [scopes page](/v3/documentation/features/advanced/scopes#the-two-arms).
## Structured Outputs
When your application needs a machine-readable answer instead of prose, pass a schema as `response_format` and the answer is guaranteed to conform to it:

View File

@ -99,7 +99,7 @@ context = session.context(summary=False, tokens=2000)
### Peer Representation in Context
You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer.
You can include a peer's [representation](/v3/documentation/core-concepts/representation) and peer card in the context by specifying `peer_target`. This is useful for providing the LLM with knowledge about a specific peer. Pass `scope` with `peer_target` to use a [named scope](/v3/documentation/features/advanced/scopes) as the perspective source (`scope` is mutually exclusive with `peer_perspective` and requires a workspace-level or admin-level key).
<CodeGroup>
```python Python
@ -119,6 +119,14 @@ context = session.context(
peer_target="user-123",
peer_perspective="assistant" # From assistant's viewpoint
)
# Or use a named scope as the perspective source (requires peer_target;
# mutually exclusive with peer_perspective)
context = session.context(
tokens=2000,
peer_target="user-123",
scope="therapy",
)
```
```typescript TypeScript
@ -139,6 +147,14 @@ context = session.context(
peerTarget: "user-123",
peerPerspective: "assistant" // From assistant's viewpoint
});
// Or use a named scope as the perspective source (requires peerTarget;
// mutually exclusive with peerPerspective)
const scopedContext = await session.context({
tokens: 2000,
peerTarget: "user-123",
scope: "therapy",
});
})();
```
</CodeGroup>
@ -211,6 +227,7 @@ context = session.context(
| `tokens` | `int` | Maximum tokens to include |
| `peer_target` | `str` | Peer ID to include representation for |
| `peer_perspective` | `str` | Peer ID for perspective (requires peer_target) |
| `scope` | `str` | Named scope as the perspective source for `peer_target`'s representation and card. Requires `peer_target` and a workspace-level or admin-level key; mutually exclusive with `peer_perspective`. See [Scopes](/v3/documentation/features/advanced/scopes) |
| `search_query` | `str` | Query for semantic search (requires peer_target) |
| `limit_to_session` | `bool` | Limit to session conclusions only |
| `search_top_k` | `int` | Semantic search results to include (1-100) |

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
{
@ -53,14 +89,14 @@ The CLI shares `~/.honcho/config.json` with sibling Honcho tools. It owns only
}
```
<Info>
Per-command scoping (workspace / peer / session) is handled via `-w` / `-p` / `-s`
Per-command targeting (workspace / peer / session) is handled via `-w` / `-p` / `-s`
flags or `HONCHO_*` env vars. **Not** persisted as CLI defaults. This is
deliberate: every invocation is explicit about what it operates on.
</Info>
### Runtime overrides
Workspace, peer, and session scoping are **per-command only** — pass flags or
Workspace, peer, and session targeting are **per-command only** — pass flags or
`HONCHO_*` env vars on every invocation.
```bash
@ -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

@ -23,7 +23,7 @@ The Honcho plugin is a community integration. See the [plugin README](https://gi
## How It Works
The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session scoping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally.
The extension hooks into pi's extension system. It automatically syncs user and assistant messages to Honcho after each agent response, injects cached user profile and project context into the system prompt with zero network latency, and exposes LLM tools (`honcho_search`, `honcho_chat`, `honcho_remember`) for active memory operations. Session mapping is configurable — memory can be shared per repo, per git branch, or per directory. If Honcho is unavailable, pi continues working normally.
## Next Steps

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

@ -61,11 +61,11 @@ In practice, that means agent peers can both be observed by Honcho and form repr
## How It Works
### Identity And Scope
### Identity And Mapping
The integration breaks down into four parts:
- **Identity and scope** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions.
- **Identity and mapping** - each Paperclip company maps to a Honcho workspace, agents and human actors map to peers, and issues map to sessions.
- **What gets copied into Honcho** - issue comments and document revisions sync into Honcho, with document content sectioned and normalized message content capped before ingestion.
- **What operators get** - operators get a plugin settings page, migration preview/status data, including a per-issue migration mapping preview, repair tools, and an issue-level `Memory` tab.
- **What agents get** - agents get Honcho retrieval and peer-chat tools inside Paperclip.
@ -130,7 +130,7 @@ The plugin registers the following Honcho tools for Paperclip agents:
Review how workspaces, peers, and sessions fit together.
</Card>
<Card title="Representation Scopes" icon="messages" href="../../documentation/features/advanced/representation-scopes">
<Card title="Directional Representations" icon="messages" href="../../documentation/features/advanced/directional-representations">
Review how `observe_me` and `observe_others` change what peers can model.
</Card>
</CardGroup>

View File

@ -109,8 +109,9 @@ and `aiPeer` there. See the [Hermes guide](/v3/guides/integrations/hermes) for t
A scheduled job feeds external data (emails, meeting notes, CRM records) into Honcho.
Attribute the messages to the peer the data is *about* — not to an agent — and group
them into a session. **How you scope that session is the main decision here**, because
it controls when Honcho reasons over the data (more on that below).
them into a session. Match the session to how you want that import's local context
to accumulate: a per-run session like `email-import-{date}`, or one ongoing
per-source session like `email-import-gmail`.
```python
from datetime import datetime, timezone
@ -131,18 +132,6 @@ for i in range(0, len(messages), 100):
session.add_messages(messages[i:i + 100])
```
Honcho batches reasoning until a peer accumulates ~1,000 tokens *within a single session*,
with a default age-based flush for quiet tails
([token batching](/v3/documentation/core-concepts/reasoning#token-batching)). Scope the
session to the volume you ingest:
- **High-volume runs** (a day of emails, a CRM export) clear the threshold easily — a
per-run session like `email-import-{date}` is fine.
- **Low-volume or trickle imports** (a few short records at a time) should append to
one **ongoing per-source session** (e.g. `email-import-gmail`), so content
accumulates across runs instead of fragmenting into thin sessions that each flush
later with little context.
The [Gmail](/v3/guides/gmail) and [Granola](/v3/guides/granola) guides are related
import examples.

View File

@ -9,7 +9,7 @@
"url": "https://honcho.dev/",
"email": "hello@plasticlabs.ai"
},
"version": "3.0.12"
"version": "3.1.0"
},
"servers": [
{
@ -1574,7 +1574,7 @@
"get": {
"tags": ["sessions"],
"summary": "Get Peer Config",
"description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.",
"description": "Get the configuration for a Peer in a Session.\n\nMember-read lets a peer-scoped key reach this route, but a peer may only\nread its own per-session config \u2014 not a co-member's. Workspace/admin and\nsession-scoped tokens (which already span the whole session) are unaffected.",
"operationId": "get_peer_config_v3_workspaces__workspace_id__sessions__session_id__peers__peer_id__config_get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
@ -2234,6 +2234,343 @@
}
}
},
"/v3/workspaces/{workspace_id}/scopes": {
"post": {
"tags": ["scopes"],
"summary": "Get Or Create Scope",
"description": "Get a Scope by ID or create a new Scope with the given ID.\n\nReturns 201 when the scope is created and 200 when it already exists.\nA pre-existing peer occupying the scope's reserved internal name is never\nadopted; that conflict returns 409.",
"operationId": "get_or_create_scope_v3_workspaces__workspace_id__scopes_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScopeCreate",
"description": "Scope creation parameters"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Scope" }
}
}
},
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Scope" }
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/list": {
"post": {
"tags": ["scopes"],
"summary": "Get Scopes",
"description": "Get all Scopes for a Workspace. Results are paginated.",
"operationId": "get_scopes_v3_workspaces__workspace_id__scopes_list_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "reverse",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Whether to reverse the order of results",
"default": false,
"title": "Reverse"
},
"description": "Whether to reverse the order of results"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Page_Scope_" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}": {
"get": {
"tags": ["scopes"],
"summary": "Get Scope",
"description": "Get a single Scope by ID.",
"operationId": "get_scope_v3_workspaces__workspace_id__scopes__scope_id__get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Scope" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions": {
"post": {
"tags": ["scopes"],
"summary": "Add Sessions To Scope",
"description": "Add Sessions to a Scope.\n\nAll named sessions must already exist (404 otherwise). Adding a session that\nis already a member is a no-op. List the resulting membership with\n`POST /scopes/{scope_id}/sessions/list`.\n\nNote: any added session that already has messages triggers an asynchronous\nbackfill-by-copy of its existing documents into the scope; track progress\nvia ``GET /scopes/{scope_id}/status``.",
"operationId": "add_sessions_to_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScopeSessionsAdd",
"description": "IDs of the sessions to add to the scope"
}
}
}
},
"responses": {
"204": { "description": "Successful Response" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}": {
"delete": {
"tags": ["scopes"],
"summary": "Remove Session From Scope",
"description": "Remove a Session from a Scope.\n\nNote: documents copied/derived while the session was a member are\nreconciled asynchronously \u2014 the session's explicit copies are soft-deleted\nfrom the scope, dependent derived documents follow (fail-closed), and the\nscope's card is rebuilt from the remaining evidence.",
"operationId": "remove_session_from_scope_v3_workspaces__workspace_id__scopes__scope_id__sessions__session_id__delete",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
},
{
"name": "session_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Session Id" }
}
],
"responses": {
"204": { "description": "Successful Response" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list": {
"post": {
"tags": ["scopes"],
"summary": "Get Scope Sessions",
"description": "Get the Sessions that are members of a Scope, paginated.\n\nOrdered by how long each session has been a member: longest-standing member\nfirst, or most recently added first when `reverse` is true.",
"operationId": "get_scope_sessions_v3_workspaces__workspace_id__scopes__scope_id__sessions_list_post",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
},
{
"name": "reverse",
"in": "query",
"required": false,
"schema": {
"type": "boolean",
"description": "Whether to reverse the order of results",
"default": false,
"title": "Reverse"
},
"description": "Whether to reverse the order of results"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Page_Session_" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/scopes/{scope_id}/status": {
"get": {
"tags": ["scopes"],
"summary": "Get Scope Status",
"description": "Get the backfill/reconciliation job status for a Scope.\n\nReturns a per-session map of the backfill job state (pending / completed /\nfailed) with the number of documents copied once complete. Empty when no\nbackfill has ever been enqueued for the scope.",
"operationId": "get_scope_status_v3_workspaces__workspace_id__scopes__scope_id__status_get",
"security": [{ "HTTPBearer": [] }],
"parameters": [
{
"name": "workspace_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Workspace Id" }
},
{
"name": "scope_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Scope Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ScopeStatus" }
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/v3/workspaces/{workspace_id}/conclusions": {
"post": {
"tags": ["conclusions"],
@ -2970,6 +3307,20 @@
"title": "Filters",
"description": "Optional filters to scope recall. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. Recall (conclusions and messages) is restricted to the allowlist; unsupported keys are rejected. When session_id is also set, it must be included in the allowlist."
},
"scope": {
"anyOf": [
{ "type": "string" },
{
"items": { "type": "string" },
"type": "array",
"maxItems": 100,
"minItems": 1
},
{ "type": "null" }
],
"title": "Scope",
"description": "Optional (unprefixed) scope name(s) to confine recall. A single scope answers from the scope's own representation of the target peer: conclusion recall is confined to what the scope observed and message recall to the scope's member sessions. A list of scopes restricts recall to the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union recalls nothing). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key."
},
"target": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Target",
@ -3227,6 +3578,22 @@
"required": ["items", "total", "page", "size", "pages"],
"title": "Page[Peer]"
},
"Page_Scope_": {
"properties": {
"items": {
"items": { "$ref": "#/components/schemas/Scope" },
"type": "array",
"title": "Items"
},
"total": { "type": "integer", "minimum": 0.0, "title": "Total" },
"page": { "type": "integer", "minimum": 1.0, "title": "Page" },
"size": { "type": "integer", "minimum": 1.0, "title": "Size" },
"pages": { "type": "integer", "minimum": 0.0, "title": "Pages" }
},
"type": "object",
"required": ["items", "total", "page", "size", "pages"],
"title": "Page[Scope]"
},
"Page_Session_": {
"properties": {
"items": {
@ -3409,6 +3776,14 @@
{ "type": "null" }
],
"title": "Filters"
},
"kind": {
"anyOf": [
{ "type": "string", "enum": ["scope", "all"] },
{ "type": "null" }
],
"title": "Kind",
"description": "Which kinds of peers to list. Omitted (default): regular peers only (scope peers are excluded). 'scope': scope peers only. 'all': every peer."
}
},
"type": "object",
@ -3429,6 +3804,20 @@
"title": "Filters",
"description": "Optional filters to scope the representation. This endpoint supports only the 'session_id' key: a session id, a list of session ids, or {\"in\": [...]}. When session_id is also set, it must be included in the allowlist."
},
"scope": {
"anyOf": [
{ "type": "string" },
{
"items": { "type": "string" },
"type": "array",
"maxItems": 100,
"minItems": 1
},
{ "type": "null" }
],
"title": "Scope",
"description": "Optional (unprefixed) scope name(s) to confine the representation. A single scope reads the scope's own representation of the target peer, formed only from the scope's member sessions. A list of scopes restricts the representation to conclusions from the union of the scopes' member sessions (explicit allowlist, fail-closed: an empty union yields an empty representation). Mutually exclusive with `filters` and `session_id`. Requires a workspace- or admin-level key."
},
"target": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Target",
@ -3595,6 +3984,72 @@
"required": ["observer", "dream_type"],
"title": "ScheduleDreamRequest"
},
"Scope": {
"properties": {
"id": { "type": "string", "title": "Id" },
"metadata": {
"additionalProperties": true,
"type": "object",
"title": "Metadata"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
}
},
"type": "object",
"required": ["id", "created_at"],
"title": "Scope",
"description": "Scope response \u2014 external view of the peer backing a scope.\n\nThe ``id`` is the unprefixed scope name; the reserved peer-name prefix is\nan internal implementation detail and never surfaces here."
},
"ScopeCreate": {
"properties": {
"id": { "type": "string", "minLength": 1, "title": "Id" },
"metadata": {
"anyOf": [
{ "additionalProperties": true, "type": "object" },
{ "type": "null" }
],
"title": "Metadata"
}
},
"type": "object",
"required": ["id"],
"title": "ScopeCreate",
"description": "Schema for creating (or getting) a scope by its unprefixed name."
},
"ScopeSessionsAdd": {
"properties": {
"session_ids": {
"items": { "type": "string" },
"type": "array",
"maxItems": 100,
"minItems": 1,
"title": "Session Ids",
"description": "IDs of existing sessions to add to the scope"
}
},
"type": "object",
"required": ["session_ids"],
"title": "ScopeSessionsAdd",
"description": "Schema for adding sessions to a scope."
},
"ScopeStatus": {
"properties": {
"backfill_status": {
"additionalProperties": {
"additionalProperties": true,
"type": "object"
},
"type": "object",
"title": "Backfill Status"
}
},
"type": "object",
"title": "ScopeStatus",
"description": "Per-session backfill/reconciliation job status for a scope.\n\n``backfill_status`` maps each session that has had a backfill enqueued to\nits current job state: ``{state, updated_at[, docs_copied]}`` where\n``state`` is ``pending``/``completed``/``failed`` and ``docs_copied`` is\npresent once a backfill completes."
},
"Session": {
"properties": {
"id": { "type": "string", "title": "Id" },
@ -3722,6 +4177,18 @@
{ "$ref": "#/components/schemas/SessionConfiguration" },
{ "type": "null" }
]
},
"scopes": {
"anyOf": [
{
"items": { "type": "string" },
"type": "array",
"maxItems": 100
},
{ "type": "null" }
],
"title": "Scopes",
"description": "Optional list of (unprefixed) scope names to add this session to. Each scope is created if it does not exist yet. If the session already has messages, its existing documents are backfilled into the scope asynchronously."
}
},
"type": "object",

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,12 +1,12 @@
[project]
name = "honcho"
version = "3.0.12"
version = "3.1.0"
description = "Honcho Server"
authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
]
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard-no-fastapi-cloud-cli]>=0.131.0",
"python-dotenv>=1.0.0",

View File

@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [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

@ -42,16 +42,22 @@ from pathlib import Path
import re
from .aio import (
ConclusionScopeAio,
ConclusionsViewAio,
HonchoAio,
PeerAio,
ScopeAio,
SessionAio,
WorkspaceConclusionsAio,
)
from .api_types import MessageCreateParams
from .base import PeerBase, SessionBase
from .api_types import (
MessageCreateParams,
ScopeBackfillJob,
ScopeResponse,
ScopeStatusResponse,
)
from .base import PeerBase, ScopeBase, SessionBase
from .client import Honcho
from .conclusions import Conclusion, ConclusionScope, WorkspaceConclusions
from .conclusions import Conclusion, ConclusionsView, WorkspaceConclusions
from .http.exceptions import (
APIError,
AuthenticationError,
@ -69,6 +75,7 @@ from .http.exceptions import (
from .message import Message
from .pagination import AsyncPage, SyncPage
from .peer import Peer
from .scope import Scope
from .session import Session
from .session_context import SessionContext, SessionSummaries, Summary
from .types import (
@ -76,6 +83,12 @@ from .types import (
DialecticStreamResponse,
)
# Deprecated aliases. "Scope" now means a named set of sessions (see `Scope`),
# which these are not — they are views over one observer/observed pair. Kept for
# one more minor version.
ConclusionScope = ConclusionsView
ConclusionScopeAio = ConclusionsViewAio
def _detect_version() -> str:
try:
@ -101,25 +114,34 @@ __all__ = [
"Honcho",
# Domain classes
"Conclusion",
"ConclusionScope",
"ConclusionsView",
"WorkspaceConclusions",
"Message",
"MessageCreateParams",
"Peer",
"Scope",
"Session",
# Aio views (for type hints)
"ConclusionScopeAio",
"ConclusionsViewAio",
"WorkspaceConclusionsAio",
"HonchoAio",
"PeerAio",
"ScopeAio",
"SessionAio",
# Base classes
"PeerBase",
"ScopeBase",
"SessionBase",
# Response types
"ScopeBackfillJob",
"ScopeResponse",
"ScopeStatusResponse",
"SessionContext",
"SessionSummaries",
"Summary",
# Deprecated aliases
"ConclusionScope",
"ConclusionScopeAio",
# Pagination
"AsyncPage",
"SyncPage",

View File

@ -3,7 +3,7 @@
This module provides async accessor classes that wrap the main SDK classes
and provide async versions of all operations. Access via the `.aio` property
on Honcho, Peer, Session, and ConclusionScope instances.
on Honcho, Peer, Session, and ConclusionsView instances.
Example:
```python
@ -24,7 +24,7 @@ from __future__ import annotations
import json
import logging
import warnings
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
@ -40,19 +40,22 @@ from .api_types import (
PeerResponse,
QueueStatusResponse,
RepresentationResponse,
ScopeBackfillJob,
ScopeResponse,
ScopeStatusResponse,
SessionConfiguration,
SessionPeerConfig,
SessionResponse,
WorkspaceConfiguration,
WorkspaceResponse,
)
from .base import PeerBase, SessionBase
from .base import PeerBase, ScopeBase, SessionBase
from .conclusions import (
_LIST_PAGE_CAP,
_SCOPE_RESERVED,
_VIEW_RESERVED,
Conclusion,
_reject_reserved_filter_keys,
_require_scope,
_require_view,
)
from .http import routes
from .message import Message
@ -66,14 +69,20 @@ from .utils import (
parse_sse_astream,
prepare_file_for_upload,
resolve_id,
resolve_scope_membership,
resolve_scope_session,
scope_context_fields,
scope_recall_fields,
validate_scope_id,
)
if TYPE_CHECKING:
from .client import Honcho
from .conclusions import ConclusionScope, WorkspaceConclusions
from .conclusions import ConclusionsView, WorkspaceConclusions
from .conclusions import ConclusionCreateParams
from .peer import Peer, TResponseFormat, serialize_response_format
from .scope import Scope
from .session import Session
logger = logging.getLogger(__name__)
@ -81,8 +90,9 @@ logger = logging.getLogger(__name__)
__all__ = [
"HonchoAio",
"PeerAio",
"ScopeAio",
"SessionAio",
"ConclusionScopeAio",
"ConclusionsViewAio",
"WorkspaceConclusionsAio",
]
@ -270,6 +280,7 @@ class HonchoAio(AsyncMetadataConfigMixin):
| list[tuple[PeerBase | str, SessionPeerConfig]]
| list[PeerBase | str | tuple[PeerBase | str, SessionPeerConfig]]
| None = None,
scopes: Sequence[str | ScopeBase] | None = None,
) -> Session:
"""
Get or create a session with the given ID asynchronously.
@ -281,6 +292,11 @@ class HonchoAio(AsyncMetadataConfigMixin):
peers: Optional peers to attach to the session at creation. Accepts the
same shape as Session.add_peers (peer ID string, Peer object, list
of either, or tuples with SessionPeerConfig).
scopes: Optional scopes this session should join, as IDs or Scope
objects. Each scope is created if it does not exist yet. Attaching
at creation avoids the asynchronous backfill a later
``scope.add_sessions()`` triggers, since there is no history to
copy.
Returns:
A Session object with cached values from the API response.
@ -293,6 +309,8 @@ class HonchoAio(AsyncMetadataConfigMixin):
body["configuration"] = configuration.model_dump(exclude_none=True)
if peers is not None:
body["peers"] = normalize_peers_to_dict(peers)
if scopes is not None:
body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes]
data = await self._honcho._async_http_client.post(
routes.sessions(self._honcho.workspace_id), body=body
@ -361,6 +379,88 @@ class HonchoAio(AsyncMetadataConfigMixin):
return AsyncPage(data, SessionResponse, transform, fetch_next)
async def scope(
self,
id: str, # noqa: A002
*,
metadata: dict[str, object] | None = None,
) -> Scope:
"""
Get or create a scope with the given ID asynchronously.
A scope is a named set of sessions that acts as a visibility boundary:
recall performed through the scope sees only what happened in its sessions,
while the underlying peer keeps its single unified representation of
everything.
Args:
id: Unprefixed scope name, unique within the workspace.
metadata: Optional metadata dictionary to associate with this scope.
Returns:
A Scope object for managing membership.
Raises:
ValueError: If the scope ID is invalid.
"""
validate_scope_id(id)
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
data = await self._honcho._async_http_client.post(
routes.scopes(self._honcho.workspace_id), body=body
)
scope_data = ScopeResponse.model_validate(data)
return Scope(
id,
self._honcho,
metadata=scope_data.metadata,
created_at=scope_data.created_at,
)
async def scopes(
self,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> AsyncPage[ScopeResponse, Scope]:
"""
Get all scopes in the current workspace asynchronously.
Args:
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
"""
await self._honcho._ensure_workspace_async()
async def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return await self._honcho._async_http_client.post(
routes.scopes_list(self._honcho.workspace_id), query=query
)
def transform(scope: ScopeResponse) -> Scope:
"""Convert a scope API response into a Scope SDK object."""
return Scope(
scope.id,
self._honcho,
metadata=scope.metadata,
created_at=scope.created_at,
)
async def fetch_next(next_page: int) -> AsyncPage[ScopeResponse, Scope]:
return AsyncPage(
await fetch(next_page), ScopeResponse, transform, fetch_next
)
return AsyncPage(await fetch(page), ScopeResponse, transform, fetch_next)
async def workspaces(
self,
filters: dict[str, object] | None = None,
@ -402,6 +502,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,
@ -412,12 +585,27 @@ class HonchoAio(AsyncMetadataConfigMixin):
limit: int = Field(
default=10, ge=1, le=100, description="Number of results to return"
),
*,
scope: str | ScopeBase | None = None,
) -> list[Message]:
"""Search for messages in the current workspace asynchronously."""
"""Search for messages in the current workspace asynchronously.
Args:
query: The search query to use
filters: Filters to scope the search.
limit: Number of results to return (1-100, default: 10)
scope: Optional scope (ID or Scope object) restricting the search to
that scope's member sessions. Mutually exclusive with a
``session_id`` filter. A scope with no member sessions matches
nothing rather than everything.
"""
await self._honcho._ensure_workspace_async()
body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit}
if scope is not None:
body["scope"] = validate_scope_id(resolve_id(scope))
data = await self._honcho._async_http_client.post(
routes.workspace_search(self._honcho.workspace_id),
body={"query": query, "filters": filters, "limit": limit},
body=body,
)
return [
Message.from_api_response(MessageResponse.model_validate(item))
@ -592,6 +780,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[TResponseFormat],
@ -604,6 +794,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: dict[str, Any] | None = None,
@ -616,6 +808,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
@ -631,6 +825,11 @@ class PeerAio(AsyncMetadataConfigMixin):
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": False}
body.update(
scope_recall_fields(
scope=scope, sessions=sessions, session_id=resolved_session_id
)
)
if target_id:
body["target"] = target_id
if resolved_session_id:
@ -659,6 +858,8 @@ class PeerAio(AsyncMetadataConfigMixin):
*,
target: str | PeerBase | None = None,
session: str | SessionBase | None = None,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
reasoning_level: Literal["minimal", "low", "medium", "high", "max"]
| None = None,
response_format: type[BaseModel] | dict[str, Any] | None = None,
@ -674,6 +875,11 @@ class PeerAio(AsyncMetadataConfigMixin):
resolved_session_id = resolve_id(session)
body: dict[str, Any] = {"query": query, "stream": True}
body.update(
scope_recall_fields(
scope=scope, sessions=sessions, session_id=resolved_session_id
)
)
if target_id:
body["target"] = target_id
if resolved_session_id:
@ -834,13 +1040,22 @@ class PeerAio(AsyncMetadataConfigMixin):
search_max_distance: float | None = Field(None, ge=0.0, le=1.0),
include_most_frequent: bool | None = None,
max_conclusions: int | None = Field(None, ge=1, le=100),
*,
scope: str | ScopeBase | Sequence[str | ScopeBase] | None = None,
sessions: Sequence[str | SessionBase] | None = None,
) -> str:
"""Get a subset of the representation of the peer asynchronously."""
"""Get a subset of the representation of the peer asynchronously.
See Peer.representation for parameter details, including the depth caveat
on ``sessions``.
"""
await self._peer._honcho._ensure_workspace_async()
session_id = resolve_id(session)
target_id = resolve_id(target)
body: dict[str, Any] = {}
body: dict[str, Any] = scope_recall_fields(
scope=scope, sessions=sessions, session_id=session_id
)
if session_id:
body["session_id"] = session_id
if target_id:
@ -1200,6 +1415,14 @@ class SessionAio(AsyncMetadataConfigMixin):
None,
description="A peer ID to get context from the perspective of.",
),
scope: str | ScopeBase | None = Field(
None,
description="A scope to use as the perspective source instead of a peer.",
),
sessions: Sequence[str | SessionBase] | None = Field(
None,
description="An allowlist of sessions confining `peer_target`'s representation to that set. This session must be one of them.",
),
limit_to_session: bool = Field(
False,
description="Whether to limit the representation to this session only.",
@ -1227,7 +1450,11 @@ class SessionAio(AsyncMetadataConfigMixin):
description="Maximum number of conclusions to include in the representation.",
),
) -> SessionContext:
"""Get optimized context for this session asynchronously."""
"""Get optimized context for this session asynchronously.
See Session.context for parameter details, including the depth caveat on
``sessions``.
"""
await self._session._honcho._ensure_workspace_async()
if peer_target is None and peer_perspective is not None:
raise ValueError(
@ -1246,6 +1473,13 @@ class SessionAio(AsyncMetadataConfigMixin):
query: dict[str, Any] = {
"summary": summary,
"limit_to_session": limit_to_session,
**scope_context_fields(
scope=scope,
sessions=sessions,
peer_target=peer_target,
peer_perspective=peer_perspective,
limit_to_session=limit_to_session,
),
}
if tokens is not None:
query["tokens"] = tokens
@ -1597,19 +1831,19 @@ class WorkspaceConclusionsAio:
return await _aget_many_conclusions(self._workspace._honcho, conclusion_ids)
class ConclusionScopeAio:
class ConclusionsViewAio:
"""
Async view of a ConclusionScope.
Async view of a ConclusionsView.
Access via `scope.aio`. Provides async versions of all ConclusionScope methods.
Shares state with the parent ConclusionScope instance.
Access via `view.aio`. Provides async versions of all ConclusionsView methods.
Shares state with the parent ConclusionsView instance.
"""
__slots__: ClassVar[tuple[str, ...]] = ("_scope",)
_scope: "ConclusionScope"
__slots__: ClassVar[tuple[str, ...]] = ("_view",)
_view: "ConclusionsView"
def __init__(self, scope: "ConclusionScope") -> None:
self._scope = scope
def __init__(self, view: "ConclusionsView") -> None:
self._view = view
async def list(
self,
@ -1629,17 +1863,17 @@ class ConclusionScopeAio:
https://honcho.dev/docs/v3/documentation/features/advanced/using-filters
"""
_reject_reserved_filter_keys(
filters, _SCOPE_RESERVED + ("session", "session_id")
filters, _VIEW_RESERVED + ("session", "session_id")
)
resolved_session_id = resolve_id(session)
filters = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
**({"session_id": resolved_session_id} if resolved_session_id else {}),
**(filters or {}),
}
return await _alist_conclusions(
self._scope._honcho, filters, page=page, size=size, reverse=reverse
self._view._honcho, filters, page=page, size=size, reverse=reverse
)
async def query(
@ -1659,11 +1893,11 @@ class ConclusionScopeAio:
filters: Optional dictionary of additional filter criteria, merged
with this scope's observer/observed (e.g. ``{"level": "deductive"}``).
"""
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
await self._scope._honcho._ensure_workspace_async()
_reject_reserved_filter_keys(filters, _VIEW_RESERVED)
await self._view._honcho._ensure_workspace_async()
filters = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
**(filters or {}),
}
@ -1675,8 +1909,8 @@ class ConclusionScopeAio:
if distance is not None:
body["distance"] = distance
data = await self._scope._honcho._async_http_client.post(
routes.conclusions_query(self._scope.workspace_id),
data = await self._view._honcho._async_http_client.post(
routes.conclusions_query(self._view.workspace_id),
body=body,
)
return [
@ -1696,10 +1930,10 @@ class ConclusionScopeAio:
observer/observed pair. Use ``honcho.aio.conclusions.get`` for a
workspace-wide lookup.
"""
return _require_scope(
await _aget_conclusion(self._scope._honcho, conclusion_id),
self._scope.observer,
self._scope.observed,
return _require_view(
await _aget_conclusion(self._view._honcho, conclusion_id),
self._view.observer,
self._view.observed,
)
async def get_many(self, conclusion_ids: list[str]) -> list[Conclusion]:
@ -1715,11 +1949,11 @@ class ConclusionScopeAio:
is not guaranteed to match the input either).
"""
return await _aget_many_conclusions(
self._scope._honcho,
self._view._honcho,
conclusion_ids,
extra_filters={
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
},
)
@ -1752,11 +1986,11 @@ class ConclusionScopeAio:
Paginated response containing Conclusion objects
"""
return await _alist_conclusions(
self._scope._honcho,
self._view._honcho,
{
"source_ids": {"contains": conclusion_id},
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
},
page=page,
size=size,
@ -1765,9 +1999,9 @@ class ConclusionScopeAio:
async def delete(self, conclusion_id: str) -> None:
"""Delete a conclusion by ID asynchronously."""
await self._scope._honcho._ensure_workspace_async()
await self._scope._honcho._async_http_client.delete(
routes.conclusion(self._scope.workspace_id, conclusion_id)
await self._view._honcho._ensure_workspace_async()
await self._view._honcho._async_http_client.delete(
routes.conclusion(self._view.workspace_id, conclusion_id)
)
async def create(
@ -1775,15 +2009,15 @@ class ConclusionScopeAio:
conclusions: list[ConclusionCreateParams | dict[str, Any]],
) -> list[Conclusion]:
"""Create conclusions in this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
await self._view._honcho._ensure_workspace_async()
def build_conclusion_payload(
item: ConclusionCreateParams | dict[str, Any],
) -> dict[str, Any]:
"""Build a single conclusion create payload."""
payload: dict[str, Any] = {
"observer_id": self._scope.observer,
"observed_id": self._scope.observed,
"observer_id": self._view.observer,
"observed_id": self._view.observed,
}
if isinstance(item, ConclusionCreateParams):
payload["content"] = item.content
@ -1799,8 +2033,8 @@ class ConclusionScopeAio:
conclusion_params = [build_conclusion_payload(c) for c in conclusions]
data = await self._scope._honcho._async_http_client.post(
routes.conclusions(self._scope.workspace_id),
data = await self._view._honcho._async_http_client.post(
routes.conclusions(self._view.workspace_id),
body={"conclusions": conclusion_params},
)
return [
@ -1817,8 +2051,8 @@ class ConclusionScopeAio:
max_conclusions: int | None = None,
) -> str:
"""Get the computed representation for this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
body: dict[str, Any] = {"target": self._scope.observed}
await self._view._honcho._ensure_workspace_async()
body: dict[str, Any] = {"target": self._view.observed}
if search_query is not None:
body["search_query"] = search_query
if search_top_k is not None:
@ -1830,9 +2064,99 @@ class ConclusionScopeAio:
if max_conclusions is not None:
body["max_conclusions"] = max_conclusions
data = await self._scope._honcho._async_http_client.post(
routes.peer_representation(self._scope.workspace_id, self._scope.observer),
data = await self._view._honcho._async_http_client.post(
routes.peer_representation(self._view.workspace_id, self._view.observer),
body=body,
)
response = RepresentationResponse.model_validate(data)
return response.representation
class ScopeAio:
"""
Async view of a Scope.
Access via `scope.aio`. Provides async versions of all Scope methods.
Shares state with the parent Scope instance.
"""
__slots__: ClassVar[tuple[str, ...]] = ("_scope",)
_scope: "Scope"
def __init__(self, scope: "Scope") -> None:
"""Create an async view backed by a sync Scope."""
self._scope = scope
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def add_sessions(self, sessions: Sequence[str | SessionBase]) -> None:
"""Add sessions to this scope asynchronously.
See Scope.add_sessions for details, including the asynchronous backfill
that sessions with existing messages trigger.
"""
session_ids = resolve_scope_membership(sessions)
await self._scope._honcho._ensure_workspace_async()
await self._scope._honcho._async_http_client.post(
routes.scope_sessions(self._scope.workspace_id, self._scope.id),
body={"session_ids": session_ids},
)
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
async def remove_session(self, session: str | SessionBase) -> None:
"""Remove a session from this scope asynchronously.
See Scope.remove_session for details on the asynchronous reconciliation.
"""
await self._scope._honcho._ensure_workspace_async()
await self._scope._honcho._async_http_client.delete(
routes.scope_session(
self._scope.workspace_id, self._scope.id, resolve_scope_session(session)
)
)
async def sessions(
self,
page: int = 1,
size: int = 50,
*,
reverse: bool = False,
) -> AsyncPage[SessionResponse, Session]:
"""Get the sessions that are members of this scope asynchronously."""
await self._scope._honcho._ensure_workspace_async()
async def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return await self._scope._honcho._async_http_client.post(
routes.scope_sessions_list(self._scope.workspace_id, self._scope.id),
query=query,
)
def transform(response: SessionResponse) -> Session:
return Session(
response.id,
self._scope._honcho,
metadata=response.metadata,
configuration=response.configuration,
created_at=response.created_at,
is_active=response.is_active,
)
async def fetch_next(next_page: int) -> AsyncPage[SessionResponse, Session]:
return AsyncPage(
await fetch(next_page), SessionResponse, transform, fetch_next
)
return AsyncPage(await fetch(page), SessionResponse, transform, fetch_next)
async def status(self) -> dict[str, ScopeBackfillJob]:
"""Get the backfill/reconciliation progress for this scope asynchronously.
See Scope.status for details.
"""
await self._scope._honcho._ensure_workspace_async()
data = await self._scope._honcho._async_http_client.get(
routes.scope_status(self._scope.workspace_id, self._scope.id)
)
return ScopeStatusResponse.model_validate(data).backfill_status

View File

@ -276,6 +276,7 @@ class SessionCreateParams(BaseModel):
metadata: dict[str, Any] | None = None
peers: dict[str, SessionPeerConfig] | None = None
configuration: SessionConfiguration | None = None
scopes: list[str] | None = None
class SessionUpdateParams(BaseModel):
@ -295,6 +296,44 @@ class SessionListParams(BaseModel):
filters: dict[str, Any] | None = None
# ==============================================================================
# Scope Types
# ==============================================================================
class ScopeResponse(BaseModel):
"""Scope API response."""
model_config = ConfigDict(populate_by_name=True) # pyright: ignore[reportUnannotatedClassAttribute]
id: str
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime.datetime
class ScopeBackfillJob(BaseModel):
"""Backfill job state for one session in a scope.
``docs_copied`` is present only once the backfill for that session completes.
"""
model_config = ConfigDict(extra="ignore") # pyright: ignore[reportUnannotatedClassAttribute]
state: Literal["pending", "completed", "failed"]
updated_at: datetime.datetime
docs_copied: int | None = None
class ScopeStatusResponse(BaseModel):
"""Scope backfill/reconciliation status API response.
``backfill_status`` is keyed by session ID and only contains sessions that
have had a backfill enqueued.
"""
backfill_status: dict[str, ScopeBackfillJob] = Field(default_factory=dict)
# ==============================================================================
# Summary Types
# ==============================================================================

View File

@ -43,3 +43,20 @@ class SessionBase(BaseModel):
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)
class ScopeBase(BaseModel):
"""Base class for Scope objects (sync and async variants).
Use this type in method signatures to accept either a scope ID string or any
Scope object.
Attributes:
id: Unprefixed scope name, unique within the workspace
workspace_id: Workspace ID for scoping operations
"""
id: str = Field(..., min_length=1, description="Unprefixed name of this scope")
workspace_id: str = Field(
..., min_length=1, description="Workspace ID for scoping operations"
)

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging
import os
from collections.abc import Mapping
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Literal
import httpx
@ -16,21 +16,29 @@ from .api_types import (
PeerConfig,
PeerResponse,
QueueStatusResponse,
ScopeResponse,
SessionConfiguration,
SessionPeerConfig,
SessionResponse,
WorkspaceConfiguration,
WorkspaceResponse,
)
from .base import PeerBase, SessionBase
from .base import PeerBase, ScopeBase, SessionBase
from .conclusions import WorkspaceConclusions
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
from .types import DialecticStreamResponse
from .utils import (
normalize_peers_to_dict,
parse_sse_stream,
resolve_id,
validate_scope_id,
)
logger = logging.getLogger(__name__)
@ -420,6 +428,10 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
None,
description="Optional peers to attach to the session at creation. Accepts the same shape as Session.add_peers.",
),
scopes: Sequence[str | ScopeBase] | None = Field(
None,
description="Optional scopes this session should join. Each scope is created if it does not exist yet.",
),
) -> Session:
"""
Get or create a session with the given ID.
@ -434,6 +446,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
peers: Optional peers to attach to the session at creation. Accepts the
same shape as Session.add_peers (peer ID string, Peer object, list
of either, or tuples with SessionPeerConfig).
scopes: Optional scopes this session should join, as IDs or Scope
objects. Each scope is created if it does not exist yet. Attaching
at creation avoids the asynchronous backfill a later
``scope.add_sessions()`` triggers, since there is no history to
copy.
Returns:
A Session object with cached metadata, configuration, created_at, and is_active.
@ -446,6 +463,8 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
body["configuration"] = configuration.model_dump(exclude_none=True)
if peers is not None:
body["peers"] = normalize_peers_to_dict(peers)
if scopes is not None:
body["scopes"] = [validate_scope_id(resolve_id(scope)) for scope in scopes]
data = self._http.post(routes.sessions(self.workspace_id), body=body)
session_data = SessionResponse.model_validate(data)
@ -515,6 +534,97 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
return SyncPage(data, SessionResponse, transform, fetch_next)
@validate_call
def scope(
self,
id: str = Field( # noqa: A002
..., min_length=1, description="Unprefixed name for the scope"
),
*,
metadata: dict[str, object] | None = Field(
None,
description="Optional metadata dictionary to associate with this scope.",
),
) -> Scope:
"""
Get or create a scope with the given ID.
A scope is a named set of sessions that acts as a visibility boundary:
recall performed through the scope sees only what happened in its sessions,
while the underlying peer keeps its single unified representation of
everything.
Args:
id: Unprefixed scope name, unique within the workspace.
metadata: Optional metadata dictionary to associate with this scope.
Returns:
A Scope object for managing membership.
Raises:
ValueError: If the scope ID is invalid.
Example:
```python
therapy = honcho.scope("therapy")
therapy.add_sessions([session_1, session_2])
```
"""
validate_scope_id(id)
self._ensure_workspace()
body: dict[str, Any] = {"id": id}
if metadata is not None:
body["metadata"] = metadata
data = self._http.post(routes.scopes(self.workspace_id), body=body)
scope_data = ScopeResponse.model_validate(data)
return Scope(
id,
self,
metadata=scope_data.metadata,
created_at=scope_data.created_at,
)
def scopes(
self,
*,
page: int = 1,
size: int = 50,
reverse: bool = False,
) -> SyncPage[ScopeResponse, Scope]:
"""
Get all scopes in the current workspace.
Args:
page: Page number (1-indexed). Default: 1.
size: Number of items per page. Default: 50.
reverse: If True, reverses the default ordering. Default: False.
Returns:
A SyncPage of Scope objects representing all scopes in the workspace.
"""
self._ensure_workspace()
def fetch(next_page: int) -> dict[str, Any]:
query: dict[str, Any] = {"page": next_page, "size": size}
if reverse:
query["reverse"] = "true"
return self._http.post(routes.scopes_list(self.workspace_id), query=query)
def transform(scope: ScopeResponse) -> Scope:
"""Convert a scope API response into a Scope SDK object."""
return Scope(
scope.id,
self,
metadata=scope.metadata,
created_at=scope.created_at,
)
def fetch_next(next_page: int) -> SyncPage[ScopeResponse, Scope]:
return SyncPage(fetch(next_page), ScopeResponse, transform, fetch_next)
return SyncPage(fetch(page), ScopeResponse, transform, fetch_next)
def workspaces(
self,
filters: dict[str, object] | None = None,
@ -583,6 +693,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,
@ -593,6 +795,11 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
limit: int = Field(
default=10, ge=1, le=100, description="Number of results to return"
),
*,
scope: str | ScopeBase | None = Field(
None,
description="Optional scope restricting the search to its member sessions",
),
) -> list[Message]:
"""
Search for messages in the current workspace.
@ -603,15 +810,22 @@ class Honcho(BaseModel, MetadataConfigMixin): # pyright: ignore[reportUnsafeMul
query: The search query to use
filters: Filters to scope the search. See [search filters documentation](https://honcho.dev/docs/v3/documentation/core-concepts/features/using-filters).
limit: Number of results to return (1-100, default: 10)
scope: Optional scope (ID or Scope object) restricting the search to
that scope's member sessions. Mutually exclusive with a
``session_id`` filter. A scope with no member sessions matches
nothing rather than everything.
Returns:
A list of Message objects representing the search results.
Returns an empty list if no messages are found.
"""
self._ensure_workspace()
body: dict[str, Any] = {"query": query, "filters": filters, "limit": limit}
if scope is not None:
body["scope"] = validate_scope_id(resolve_id(scope))
data = self._http.post(
routes.workspace_search(self.workspace_id),
body={"query": query, "filters": filters, "limit": limit},
body=body,
)
return [
Message.from_api_response(MessageResponse.model_validate(item))

View File

@ -15,31 +15,31 @@ from .pagination import SyncPage
from .utils import resolve_id
if TYPE_CHECKING:
from .aio import ConclusionScopeAio, WorkspaceConclusionsAio
from .aio import ConclusionsViewAio, WorkspaceConclusionsAio
from .client import Honcho
__all__ = [
"Conclusion",
"ConclusionScope",
"ConclusionsView",
"ConclusionCreateParams",
"WorkspaceConclusions",
]
_LIST_PAGE_CAP = 100
# Filter keys that define a conclusion scope (the observer/observed peer pair).
# They are set from the scope itself, so a caller must not pass them in `filters`.
_SCOPE_RESERVED = ("observer", "observed", "observer_id", "observed_id")
# Filter keys that define a conclusions view (the observer/observed peer pair).
# They are set from the view itself, so a caller must not pass them in `filters`.
_VIEW_RESERVED = ("observer", "observed", "observer_id", "observed_id")
def _reject_reserved_filter_keys(
filters: dict[str, Any] | None, reserved: tuple[str, ...]
) -> None:
"""Raise if ``filters`` contains keys managed by the conclusion scope.
"""Raise if ``filters`` contains keys managed by the conclusions view.
The observer/observed peer pair (and, on ``list``, the session) is fixed by
the scope, so letting a user filter override it would silently return data
from a different scope than requested. Fail loud instead.
the view, so letting a user filter override it would silently return data
from a different pair than requested. Fail loud instead.
"""
if not filters:
return
@ -51,7 +51,7 @@ def _reject_reserved_filter_keys(
if "session" in reserved or "session_id" in reserved:
guidance += "; use the session= parameter to filter by session"
raise ValueError(
f"Filter key(s) {clash} are managed by this conclusion scope and "
f"Filter key(s) {clash} are managed by this conclusions view and "
+ f"cannot be passed in filters. {guidance}."
)
@ -73,7 +73,7 @@ def _get_conclusion(honcho: "Honcho", conclusion_id: str) -> Conclusion:
return _conclusion_from_item(data)
def _require_scope(
def _require_view(
conclusion: Conclusion, observer_id: str, observed_id: str
) -> Conclusion:
if conclusion.observer_id != observer_id or conclusion.observed_id != observed_id:
@ -282,7 +282,7 @@ class WorkspaceConclusions:
return f"WorkspaceConclusions(workspace_id={self.workspace_id!r})"
class ConclusionScope:
class ConclusionsView:
"""
Scoped access to conclusions for a specific observer/observed relationship.
@ -321,7 +321,7 @@ class ConclusionScope:
observed: str,
):
"""
Initialize a ConclusionScope.
Initialize a ConclusionsView.
Args:
honcho: The Honcho client instance
@ -335,12 +335,12 @@ class ConclusionScope:
self.observed = observed
@property
def aio(self) -> "ConclusionScopeAio":
def aio(self) -> "ConclusionsViewAio":
"""
Access async versions of all ConclusionScope methods.
Access async versions of all ConclusionsView methods.
Returns a ConclusionScopeAio view that provides async versions of all methods
while sharing state with this ConclusionScope instance.
Returns a ConclusionsViewAio view that provides async versions of all methods
while sharing state with this ConclusionsView instance.
Example:
```python
@ -350,9 +350,9 @@ class ConclusionScope:
```
"""
# Import here to avoid circular import (aio.py imports from this module)
from .aio import ConclusionScopeAio
from .aio import ConclusionsViewAio
return ConclusionScopeAio(self)
return ConclusionsViewAio(self)
def list(
self,
@ -384,7 +384,7 @@ class ConclusionScope:
Paginated response containing Conclusion objects
"""
_reject_reserved_filter_keys(
filters, _SCOPE_RESERVED + ("session", "session_id")
filters, _VIEW_RESERVED + ("session", "session_id")
)
resolved_session_id = resolve_id(session)
filters = {
@ -421,7 +421,7 @@ class ConclusionScope:
Returns:
List of matching Conclusion objects
"""
_reject_reserved_filter_keys(filters, _SCOPE_RESERVED)
_reject_reserved_filter_keys(filters, _VIEW_RESERVED)
self._honcho._ensure_workspace()
filters = {
"observer_id": self.observer,
@ -462,7 +462,7 @@ class ConclusionScope:
observer/observed pair. Use ``honcho.conclusions.get`` for a
workspace-wide lookup.
"""
return _require_scope(
return _require_view(
_get_conclusion(self._honcho, conclusion_id),
self.observer,
self.observed,
@ -662,6 +662,6 @@ class ConclusionScope:
def __repr__(self) -> str:
return (
f"ConclusionScope(workspace_id={self.workspace_id!r}, "
f"ConclusionsView(workspace_id={self.workspace_id!r}, "
f"observer={self.observer!r}, observed={self.observed!r})"
)

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"
@ -102,6 +106,31 @@ def session_peer_config(workspace_id: str, session_id: str, peer_id: str) -> str
return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/peers/{peer_id}/config"
# Scope routes
def scopes(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes"
def scopes_list(workspace_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/list"
def scope_sessions(workspace_id: str, scope_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions"
def scope_sessions_list(workspace_id: str, scope_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/list"
def scope_session(workspace_id: str, scope_id: str, session_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/sessions/{session_id}"
def scope_status(workspace_id: str, scope_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/scopes/{scope_id}/status"
# Message routes
def messages(workspace_id: str, session_id: str) -> str:
return f"/{API_VERSION}/workspaces/{workspace_id}/sessions/{session_id}/messages"

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