Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 99fb8ee4fd feat(cli): cover every OpenAPI endpoint and request-body param
Wires the Rust CLI up to every endpoint exposed by the Hindsight OpenAPI
spec and adds CI enforcement so new endpoints or new request-body fields
cannot slip in without matching CLI coverage.

Endpoints
- New `hindsight webhook {list,create,update,delete,deliveries}` and
  `hindsight audit {list,stats}` subcommands.
- `hindsight bank` gains `set-disposition`, `consolidation-recover`,
  `export-template`, `import-template`, `template-schema`.
- `hindsight memory` gains `history` and per-memory `clear-observations`.
- `hindsight document update`, `hindsight operation retry` added.
- Brings CLI coverage from 46/62 to 62/62 operations.

Request-body parameters
- Expose missing flags that the CLI was silently hardcoding: directive
  `--priority`; mental-model `--tags` / `--max-tokens` /
  `--trigger-refresh-after-consolidation`; recall `--query-timestamp`;
  reflect `--fact-types` / `--exclude-mental-models` /
  `--exclude-mental-model-ids`; retain `--document-tags`.

CI enforcement
- New `cli-coverage-check` entry point in `hindsight-dev` parses
  openapi.json and verifies that (a) every operationId is called from
  hindsight-cli/src/ (the progenitor client method names match the
  operationId), and (b) every request-body property is present in
  main.rs as a clap field or `long = "..."` attribute.
- Intentional non-exposures live in `hindsight-cli/.openapi-coverage.toml`
  under `[skip]` / `[fields.<op>]` with a reason each (38 documented
  field skips for flattened structs, nested structs, or fields surfaced
  via a different subcommand).
- New `check-cli-coverage` job in .github/workflows/test.yml, triggered
  on cli/core/dev/ci path changes, runs the script on every PR.
- smoke-test.sh exercises the new webhook / audit / bank-template /
  set-disposition / consolidation-recover commands.
2026-04-10 16:13:24 +02:00
Nicolò Boschi 576016f5dc feat: add @vectorize-io/hindsight-all daemon lifecycle package (#949)
* feat: add @vectorize-io/hindsight-embed daemon lifecycle package

Create a new top-level `hindsight-embed-npm/` package that owns the daemon
lifecycle for the Python `hindsight-embed` CLI: spawning via `uvx`, writing
the profile, waiting for `/health`, and shutting down. Nothing more.

Deliberately does not ship an HTTP client — `@vectorize-io/hindsight-client`
already covers retain / recall / reflect / createBank against the Hindsight
API, and the two packages compose: once `manager.start()` returns, consumers
talk to the daemon via `new HindsightClient({ baseUrl: manager.getBaseUrl() })`.

`HindsightEmbedManagerOptions.env` forwards an arbitrary `Record<string,
string>` to both the daemon process and the profile config via `--env K=V`,
and `extraProfileCreateArgs` / `extraDaemonStartArgs` escape hatches cover
any new CLI flag without waiting for a wrapper release.

Refactor `hindsight-integrations/openclaw` to consume both packages:
`HindsightEmbedManager` for daemon lifecycle in local mode, `HindsightClient`
for all HTTP memory operations. Drop the bespoke subprocess/HTTP client that
used to live in openclaw. The retain queue stays local to openclaw (it's a
client-side reliability workaround with a single consumer today — will move
to the client package or server-side when a second consumer needs it).

Wire the new package into the main release pipeline (versioned alongside
the other core packages, published from `v*` tags) and add a CI build job.

* docs: add Embedded Node.js SDK page for @vectorize-io/hindsight-embed

* refactor: rename hindsight-embed-npm to hindsight-all, restructure docs sidebar

The Node package previously named @vectorize-io/hindsight-embed was
semantically misnamed: hindsight-embed (Python) is a CLI tool, while what
this Node package actually provides is the Node equivalent of hindsight-all
— a programmatic lifecycle manager for a local Hindsight daemon. Rename to
match.

Package rename
  - hindsight-embed-npm/ → hindsight-all-npm/ (git mv, history preserved)
  - @vectorize-io/hindsight-embed → @vectorize-io/hindsight-all
  - class HindsightEmbedManager → HindsightServer (matches Python hindsight-all)
  - HindsightEmbedManagerOptions → HindsightServerOptions
  - src/manager.ts → src/server.ts, src/manager.test.ts → src/server.test.ts
  - openclaw (index.ts, backfill.ts, tests) and the claude-code Python port
    updated to reference the new names

Docs restructure
  - Split sdks/python.md: now client-only content. New sdks/hindsight-all.md
    covers the programmatic hindsight-all Python package (HindsightServer and
    HindsightEmbedded).
  - Rename sdks/embed-npm.md → sdks/hindsight-all-npm.md with HindsightServer
    examples.
  - New "Installation" sidebar section, placed after Hosting, containing
    Docker / Kubernetes / Bare Metal (anchor links into developer/installation)
    plus Programmatic API (Python), Programmatic API (Node.js), and Daemon CLI.
  - Add si-docker, si-kubernetes, si-nodedotjs, lu-hard-drive to the sidebar
    ICON_MAP.

Docs dev-server fix
  - docusaurus.config.ts: drop the flaky NODE_ENV sniff for including the
    "Next" version. Use INCLUDE_CURRENT_VERSION exclusively. NODE_ENV was
    unreliable across hot-reload paths and caused the Next version to
    disappear intermittently when editing files.
  - scripts/dev/start-docs.sh: export INCLUDE_CURRENT_VERSION=true so local
    dev always shows Next; production builds leave it unset.

Lockfile cleanup
  - package-lock.json and hindsight-integrations/openclaw/package-lock.json
    had extraneous hindsight-embed-npm blocks left over from the rename.
    Removed manually and verified with npm install.

* ci: fix openclaw jobs by pre-building workspace deps; regenerate docs-skill

The build-openclaw-integration and test-openclaw-integration jobs failed
with "Failed to resolve entry for package @vectorize-io/hindsight-all"
because openclaw depends on two monorepo workspaces via `file:` deps
(@vectorize-io/hindsight-client and @vectorize-io/hindsight-all) whose
`dist/` directories are gitignored and never built before openclaw's npm ci.
Both jobs now install the root workspace and build the two deps first,
mirroring the release-control-plane pattern.

Also regenerate skills/hindsight-docs/references/* via
./scripts/generate-docs-skill.sh:
  - new skill pages for sdks/hindsight-all{.md,-npm.md}
  - updated skill pages for sdks/embed.md and sdks/python.md to match
    the new H1s and split content
  - incidental refreshes to changelog/index.md, developer/models.md,
    openapi.json, and uv.lock that verify-generated-files picked up

* ci: build openclaw before running tests so symlink test can realpath dist
2026-04-10 15:51:44 +02:00
r266-tech b3995d1430 docs: document update_mode parameter in retain API (#959)
PR #932 added update_mode (replace/append) to retain items but
did not update the docs. Add a section explaining the parameter,
when to use append mode, and a JSON example.

Closes #957
2026-04-10 10:22:51 +02:00
Ben f519fc4fd0 blog: Agno Persistent Memory (#951)
* blog: add Agno persistent memory post
2026-04-09 14:27:08 -04:00
YUAN TIANJIANandNicolò Boschi 72fd3d59db feat(openclaw): add config-aware history backfill CLI (#878)
* Add OpenClaw history backfill CLI

* Fix backfill resume and local daemon behavior

* Fix backfill checkpoint finalization semantics

* Fix symlinked backfill CLI entrypoint detection

* fix(ci): skip PR status write for fork approvals

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-04-09 10:44:19 +02:00
5a61ac50e9 feat(openclaw): add session pattern filtering for ignore and stateless sessions (#909)
* feat(openclaw): add session pattern filtering for ignore and stateless sessions

Adds three new config options to the OpenClaw plugin that allow filtering
sessions by key pattern before recall and retain operations fire:

- `ignoreSessionPatterns`: glob patterns for sessions to skip entirely
  (no recall, no retain). Useful for cron/scheduled agent sessions.
- `statelessSessionPatterns`: glob patterns for read-only sessions —
  retain is always skipped; recall is also skipped when
  `skipStatelessSessions` is true (default).
- `skipStatelessSessions`: boolean (default: true). When false, sessions
  matching statelessSessionPatterns can still recall but never retain.

Pattern syntax mirrors lossless-claw: `*` matches non-colon characters,
`**` matches anything including colons. Session keys follow the OpenClaw
format `agent:<agentId>:<type>:<uuid>`.

Example config:
  ignoreSessionPatterns:    ["agent:*:cron:**"]
  statelessSessionPatterns: ["agent:*:subagent:**", "agent:*💓**"]
  skipStatelessSessions:    true

Implementation:
- New `session-patterns.ts` module with compile/match utilities
- Session filter applied in `before_prompt_build` and `agent_end` hooks
  immediately after the existing `excludeProviders` check
- New fields wired through `getPluginConfig`
- Schema added to `openclaw.plugin.json` (additionalProperties: false
  was already set, causing config validation errors without this)
- 11 unit tests in `session-patterns.test.ts`
- 5 integration tests added to `hooks.integration.test.ts`

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test(openclaw): support HINDSIGHT_API_TOKEN in integration tests

Pass HINDSIGHT_API_TOKEN env var through to HindsightClient and plugin
config in integration tests so tests work against authenticated APIs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* docs(openclaw): document session pattern filtering options

Add ignoreSessionPatterns, statelessSessionPatterns, and skipStatelessSessions
to the README config table with glob syntax reference and usage examples.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Marco Rutsch <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-09 10:43:35 +02:00
1f1716bdb0 feat(openclaw): add resilient startup and richer retain metadata (#942)
* feat(openclaw): enrich retain metadata and ignore heartbeat by default

* docs(openclaw): move retain metadata note out of config table

* fix(openclaw): make hook registration runtime-idempotent

* fix(openclaw): lazily initialize when service start is skipped

---------

Co-authored-by: Aldous <[email protected]>
Co-authored-by: Josh <[email protected]>
Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-09 10:43:08 +02:00
Nicolò Boschi 61a8014f9d docs: 0.5.0 release notes, changelog, and blog post (#907)
* docs: add 0.5.0 release notes and changelog

* docs: include all commits since v0.4.22 and add recall perf to blog

* docs: include all commits since v0.4.22 and add recall perf to blog

* docs: add openrouter default model to provider table

* docs: reorder blog sections, fix code snippets, remove paperclip

* docs: add hermes integration docs link

* docs: fix broken anchor in blog post TOC
2026-04-08 18:45:20 +02:00
Nicolò Boschi c5091d29cd fix(deps): pin greenlet<3.4.0 — missing arm64 wheels in 3.4.0 2026-04-08 18:43:42 +02:00
Nicolò Boschi e82bc56580 fix(docker): constrain greenlet<3.4.0 for arm64 Docker builds
greenlet 3.4.0 lacks manylinux_2_41_aarch64 wheels. Use a UV_CONSTRAINT
file instead of the workspace lock file (which doesn't work in the
single-package Docker context).
2026-04-08 18:34:05 +02:00
Nicolò Boschi fa0e63b088 fix(docker): copy uv.lock into build context to pin greenlet version
Without the lock file, uv sync resolves fresh and picks up greenlet
3.4.0 which lacks arm64 wheels for manylinux_2_41, breaking the
multi-arch Docker build.
2026-04-08 18:21:28 +02:00
Nicolò Boschi 27cb7e43e0 Release v0.5.0
- Update version to 0.5.0 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Create documentation version-0.5
2026-04-08 17:56:47 +02:00
Ben 9e23e83abf Add Codex persistent memory blog post (#812)
* Add Codex persistent memory blog post
2026-04-08 10:44:27 -04:00
Nicolò Boschi bdf93f0660 fix: exclude local-llm from [all] extra, add as opt-in to hindsight-all (#936)
* fix: exclude local-llm from [all] extra to avoid heavy llama-cpp-python dep

local-llm (llama-cpp-python) requires C++ compilation and is only needed
for the built-in llamacpp provider. Keep it as a separate opt-in:
pip install 'hindsight-api-slim[local-llm]'

* feat: add local-llm optional extra to hindsight-all

Allows: pip install 'hindsight-all[local-llm]' to get built-in llamacpp support.

* chore: regenerate uv.lock from workspace root
2026-04-08 16:06:26 +02:00
AldousandAldous the Orchestrator b0e8ac0f4d feat(openclaw): add configurable retain tags (#937)
Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-08 15:52:32 +02:00
Nicolò Boschi f74b577e02 feat: add built-in llama.cpp LLM provider for local inference (#933)
* feat: add built-in llama.cpp LLM provider for fully local inference

Add `llamacpp` as a new LLM provider that manages a llama-cpp-python server
subprocess. Auto-downloads Gemma 4 E2B Q4_K_M (~3.5 GB) on first use and
runs inference locally via Metal/CUDA with no external services needed.

- New provider: `HINDSIGHT_API_LLM_PROVIDER=llamacpp`
- Singleton server shared across retain/reflect/consolidation
- Configurable: model path, GPU layers, context size, grammar enforcement
- User-extensible via `HINDSIGHT_API_LLAMACPP_EXTRA_ARGS`
- Flash attention + prompt caching enabled by default
- LLM provider cleanup on shutdown (stops subprocess)
- hindsight-embed: `--ui` flag on `daemon start`, removed FORCE_CPU on macOS
- Docs: configuration.md, models.mdx, providers grid updated

* chore: regenerate docs skill and update lockfile for local-llm dep
2026-04-08 15:22:10 +02:00
Nicolò Boschi 3c633e5e16 feat: add retain update_mode='append' for document content concatenation (#932)
* feat: add update_mode='append' for retain to concatenate content to existing documents

When retaining with update_mode='append' and a document_id that already exists,
the new content is appended to the existing document text and the full document
is reprocessed. Delta retain automatically skips unchanged chunks, so only the
new content triggers LLM extraction.

- Add update_mode field to MemoryItem (API), RetainContentDict (internal), MCP tools
- Validate that update_mode='append' requires a document_id
- Fetch existing document content and prepend before processing in orchestrator
- Update Python, TypeScript, Go generated clients and top-level client wrappers
- Add tests for append, multiple appends, no-existing-doc, validation, and default replace

* fix: add update_mode field to Rust CLI and client MemoryItem initializers

* chore: regenerate docs skill references for update_mode
2026-04-08 14:39:16 +02:00
Nicolò Boschi cf0537ba7e chore: drop hindsight-hermes integration (#931)
* chore: drop hindsight-hermes integration in favor of native Hermes memory provider

Hermes Agent now ships with a native Hindsight memory provider (NousResearch/hermes-agent#5094),
making our pip-installable hindsight-hermes package redundant.

Removes:
- hindsight-integrations/hermes/ (source, tests, config)
- CI job, release script entry, changelog generator references
- Cookbook page and pip package changelog (referenced deleted code)

Keeps:
- Integration docs (updated by #881 for native provider)
- Blog posts (historical, already have deprecation notices)
- Sidebar/banner entries (still valid for native integration)

* fix(docs): remove broken cookbook link to deleted hermes-memory page
2026-04-08 11:59:35 +02:00
Nicolò Boschi e5944b63e7 feat: add OpenRouter support for LLM, embeddings, and reranking (#930)
* docs: add best practice for filtering recall by memory shape (#856)

Add guidance on using entity labels with `tag: true` to deterministically
filter recall results when a bank contains different memory shapes
(e.g., concise rules vs. detailed procedures).

* feat: add OpenRouter support for LLM, embeddings, and reranking

OpenRouter is OpenAI-compatible for chat/embeddings and Cohere-compatible
for reranking, so no new provider classes are needed.

- LLM: added as OpenAICompatibleLLM provider (default model: qwen/qwen3.5-9b)
- Embeddings: reuses OpenAIEmbeddings with OpenRouter base URL (default: perplexity/pplx-embed-v1-0.6b)
- Reranker: reuses CohereCrossEncoder with OpenRouter rerank endpoint (default: cohere/rerank-v3.5)
- API key fallback chain: dedicated key → shared OPENROUTER_API_KEY → LLM_API_KEY

* chore: regenerate docs skill references and fix formatting
2026-04-08 11:24:21 +02:00
Nicolò Boschi 37348c859e feat: include occurred_end and mentioned_at in think-prompt fact serialization (#929)
Extend format_facts_for_prompt() to include occurred_end and mentioned_at
temporal fields (when non-null), matching the MemoryFact model. Also add
RecallResponse.to_prompt_string() to Python and TypeScript client SDKs so
users can serialize recall results (with chunks and entity summaries) into
LLM-ready prompt strings.

Closes #924
2026-04-08 10:33:14 +02:00
Nicolò Boschi cece2c903c fix: make LiteLLM SDK embeddings encoding_format configurable (#928)
* fix: make LiteLLM SDK embeddings encoding_format configurable (#925)

The hardcoded encoding_format='float' breaks providers like Voyage AI
(only accepts 'base64') and Gemini (doesn't support the parameter at all).

Add HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT config option
that defaults to 'float' for backwards compatibility. Set to empty string
to omit the parameter for incompatible providers.

* chore: regenerate docs skill after configuration change
2026-04-08 09:41:11 +02:00
Derek Bouius d7c73f4342 security: bump lodash, lodash-es, defu in root lockfile (#915)
* security: bump lodash, lodash-es, and defu in root lockfile

Fixes Dependabot alerts in the root npm workspace lockfile:
- GHSA-r5fr-rjxr-66jc (high) lodash <4.18.1     (alert #338)
- GHSA-r5fr-rjxr-66jc (high) lodash-es <4.18.1  (alert #335)
- GHSA-737v-mqg7-c878 (high) defu <6.1.7        (alert #343)

defu (6.1.4 -> 6.1.7) and lodash (4.17.23 -> 4.18.1) were bumped via
targeted `npm update`. lodash-es was pinned exactly to 4.17.23 by
@chevrotain packages (transitive dep of mermaid in hindsight-docs),
so a `lodash-es` override (>=4.18.1) is added to the root package.json
to force resolution to the patched 4.18.1.

Verified: `npm ci` succeeds with 0 vulnerabilities. Mermaid/chevrotain
consumers all dedupe to lodash-es 4.18.1. lodash-es 4.x is semver-
compatible.

* chore: regenerate hindsight-docs skill

Picks up FAQ and best-practice sections added in #905 that were not
regenerated at merge time, so that `verify-generated-files` passes
for this branch.
2026-04-08 09:11:29 +02:00
Derek Bouius 3b9d2db091 security: bump vite across integrations (high CVE fix) (#913)
* security: bump vite across integrations to patched versions

Fixes Dependabot alerts for vite transitive dev dependency:
- GHSA-v2wj-q39q-566r (high): server.fs.deny bypass with queries
- GHSA-p9ff-h696-f583 (high): related vite server vulnerability

Adds a `vite` entry to the npm `overrides` in each integration's
package.json to force the patched version (>=8.0.5). To make this
possible in ai-sdk, chat, and openclaw — which pinned vitest ^4.0.18
whose vite peer is `^6.0.0 || ^7.0.0` — the minor-compatible bump
vitest ^4.0.18 -> ^4.1.2 is also included. vitest 4.1.x supports
vite 8.x (peer: ^6 || ^7 || ^8), so all six integrations converge on
vite 8.x consistently.

paperclip had no overrides block; one was added.

Verified locally: `npm ci && npx vitest run` passes in all six
integrations (ai-sdk 23, chat 28, openclaw 66, opencode 89, paperclip 27,
nemoclaw 36 tests).

* chore: regenerate hindsight-docs skill

Picks up FAQ and best-practice sections added in #905 that were not
regenerated at merge time, so that `verify-generated-files` passes
for this branch.
2026-04-08 09:11:21 +02:00
easonandeasonysliu 9790d904e0 fix: clamp out-of-range content_index in _map_results_to_contents (#908)
Some LLM providers (e.g. Anthropic Haiku) return 1-indexed
content_index values. When only one content item is provided,
this causes KeyError: 1 since the dict only has key 0.

Clamp content_index to the valid range instead of crashing.

Fixes #873

Co-authored-by: easonysliu <[email protected]>
2026-04-08 09:10:59 +02:00
Ben 2463efd0f2 Update author name from Mike to Michael (#917) 2026-04-07 13:42:29 -04:00
Ben 6674ee4706 Remove hindsight-cloud tag from guest post (#916) 2026-04-07 13:22:48 -04:00
Nicolò Boschi 57f154454d fix(recall): cap entity fanout in graph expansion (#911)
* fix(recall): cap entity fanout in graph expansion to prevent slow queries

On large banks, the entity co-occurrence self-join in _expand_combined()
produces massive intermediate row counts when seeds reference high-fanout
entities (e.g. an entity with 25K+ mentions). This causes recall latency
to degrade significantly.

Changes:
- Replace unbounded entity self-join with LATERAL per-entity cap
  (graph_per_entity_limit, default 200), reducing intermediate rows
  from potentially millions to at most num_entities * 200
- Add ORDER BY unit_id DESC in LATERAL subquery for deterministic
  recency-biased sampling (rides the PK index, no extra sort)
- Add timeout fallback (graph_expansion_timeout, default 10s) that
  drops entity expansion and falls back to semantic+causal only
- Add composite index (entity_id, unit_id) on unit_entities for
  index-only scans in the LATERAL subquery
- Merge 3 unmerged migration heads into one
- Fix recall_perf.py dotenv override issue

Unlike the approach in #895, this does NOT filter out hub entities
entirely — all entities are kept but capped equally, preserving
retrieval quality for queries about frequently-mentioned entities.

Benchmarked on a 67K-unit bank (top entity = 25K mentions):
- retrieval_graph: 0.337s → 0.055s (84% faster)
- end-to-end recall: 0.912s → 0.519s (43% faster)

* fix(tests): fix broken test_combined_scoring and test_reranking_proof_count

- test_combined_scoring: replace MagicMock(spec=RetrievalResult) with real
  dataclass instances — MagicMock attributes returned nested mocks that
  failed on >= comparisons with int
- test_reranking_proof_count: remove deleted `embedding` param from
  RetrievalResult constructor, use None for occurred_start/end to get
  neutral recency (datetime.now gave recency=1.0 which boosted scores)

* refactor: rename config to link_expansion_ prefix, fix observation fanout

- Rename GRAPH_PER_ENTITY_LIMIT → LINK_EXPANSION_PER_ENTITY_LIMIT and
  GRAPH_EXPANSION_TIMEOUT → LINK_EXPANSION_TIMEOUT to follow the
  convention that these are specific to the link_expansion graph retriever
- Apply the same LATERAL per-entity cap to _expand_observations(), which
  had the same unbounded self-join through unit_entities

* style: fix formatting in config.py
2026-04-07 18:59:50 +02:00
Ben 4028dd91f8 blog: One Memory for Every AI Tool I Use (#914)
* blog: One Memory for Every AI Tool I Use (guest post)
2026-04-07 12:57:48 -04:00
AldousandAldous the Orchestrator 0e81d1a25e feat(openclaw): support bankId for static banks (#910)
* feat(openclaw): support exact static bank ids

* test(openclaw): use generic static bank id example

* feat(openclaw): support bankId static bank configuration

---------

Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-07 17:13:20 +02:00
Derek Bouius 8a2388a48f security: bump litellm to >=1.83.0 (#912)
Fixes Dependabot alerts:
- GHSA-jjhc-v7c2-5hh6 (critical): Authentication bypass via OIDC userinfo
  cache key collision (CVE-2026-35030)
- GHSA-53mr-6c8q-9789 (high): related litellm vulnerability

Updates both hindsight-api-slim and hindsight-integrations/litellm to
require litellm >=1.83.0. The previous upper cap (<=1.82.6) was set due
to the 1.82.7/1.82.8 supply chain compromise, which has since been yanked
from PyPI; 1.83.0 was published from the new secure CI/CD v2 pipeline
and is safe.

The uv.lock diffs are large because the current uv version (0.9.11)
upgrades the lockfile format (adds revision=3 and upload-time fields);
only litellm itself changes version (1.81.10/1.80.10 -> 1.83.0).

All 68 tests in hindsight-integrations/litellm pass against 1.83.0.
2026-04-07 16:54:24 +02:00
Nicolò Boschi 48185a4bee fix(mcp): validate UUID inputs and add sync_retain tool (#906)
* fix(mcp): validate UUID inputs at engine level and add sync_retain tool (#888)

- Add UUID validation in memory_engine for get_memory_unit, delete_memory_unit,
  get_mental_model, delete_mental_model, get_mental_model_history (raises ValueError)
- Catch ValueError → 400 in HTTP route handlers
- Add sync_retain MCP tool that calls retain_batch_async directly for immediate
  availability (no polling needed)
- Register sync_retain in _ALL_TOOLS, _SINGLE_BANK_TOOLS, UI MCP_TOOL_GROUPS
- Add code-review check for MCP tool registration completeness

* fix: remove UUID validation for mental model IDs (column is TEXT, not UUID)

Mental model IDs are TEXT columns that accept arbitrary string IDs
(e.g., 'team-communication-preferences'). UUID validation was incorrectly
added to get_mental_model, delete_mental_model, and get_mental_model_history.
2026-04-07 11:59:59 +02:00
Nicolò Boschi 7e23f8e149 fix(config): validate entity_labels structure on PATCH (#902)
* test: add regression tests for #874 and #894

Add tests for None event_date in fact extraction (AttributeError fix)
and for _register_profile skipping .env overwrite with short config keys.

* fix(config): validate entity_labels structure on PATCH (#891)

Config PATCH accepted bare strings in entity_labels values without
validation, causing silent failures at retain time. Now validates
via parse_entity_labels() before writing to DB, and fixes the
BankTemplateConfig type from list[str] to list[dict[str, Any]].

* fix(scripts): handle Python client generator README crash gracefully

The openapi-generator sometimes crashes writing README_onlypackage.mustache.
Allow the failure with || true since all API/model files are generated
before that step, and add a verification check for api_client.py.

* chore: regenerate docs skill openapi.json
2026-04-07 11:58:02 +02:00
Nicolò Boschi f659bb17c4 docs: add best practice for filtering recall by memory shape (#856) (#905)
Add guidance on using entity labels with `tag: true` to deterministically
filter recall results when a bank contains different memory shapes
(e.g., concise rules vs. detailed procedures).
2026-04-07 10:41:32 +02:00
Nicolò Boschi f31f82627c fix: add paperclip and opencode to changelog generator (#903)
* fix: add paperclip and opencode to changelog valid integrations

* fix: add paperclip and opencode package names to changelog generator

* release(paperclip): v0.1.1
2026-04-07 10:25:53 +02:00
e1c6220f0e feat: add OpenCode persistent memory plugin (#853)
* feat: add OpenCode persistent memory plugin

Add hindsight-opencode integration with:
- Three custom tools: hindsight_retain, hindsight_recall, hindsight_reflect
- Auto-retain on session.idle with document_id deduplication
- Memory injection on session start via system transform hook
- Memory preservation during context window compaction
- Sliding window retain with retainOverlapTurns support
- 4-level config hierarchy (defaults, user file, plugin options, env vars)
- Dynamic bank ID derivation (agent, project, channel, user dimensions)
- CI job, release script entry, docs page

79 tests across 6 test files.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review findings for opencode integration

1. Pre-compaction retain now uses shared retainSession() helper,
   respecting retainMode, documentId, and session_id metadata
   consistently with idle-retain (was bypassing retention policy).

2. System transform recall is only consumed after successful injection.
   If Hindsight is briefly unavailable, the plugin retries on the next
   LLM call instead of permanently skipping recall for the session.

3. Config validation for retainMode and recallBudget — typos like
   "full_session" or "maximum" now log a warning and fall back to
   the default instead of silently changing retention semantics.

85 tests (6 new covering compaction documentId, recall retry, and
config validation).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: docs/tools findings from second review round

1. Remove "session" from supported dynamic bank fields in docs —
   the implementation can't vary bank ID per session since it's
   derived once at plugin startup.

2. Explicit tools (retain, reflect) now call ensureBankMission()
   before API calls, so bankMission/retainMission are applied even
   when the agent uses tools exclusively without triggering hooks.

3. Added tests for mission setup via tools path.

88 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: recall retry semantics and README bank scoping clarity

1. recallForContext now returns { context, ok } to distinguish
   "no results" (ok=true) from "API error" (ok=false). System
   transform consumes the session on ok=true even with 0 results,
   so empty banks don't cause repeated queries. Only transient API
   failures preserve retry.

2. README clarifies that channel/user bank dimensions are process-
   scoped (set via env vars before launch), not per-session dynamic
   within a running OpenCode process.

89 tests pass.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: review fixes for opencode integration

- Rename CI job from build-opencode-integration to test-opencode-integration
  to match naming convention for integrations that run tests
- Fix tsconfig module resolution to Node16 (consistent with other integrations)
- Extract shared makeConfig test helper to avoid duplication across 3 test files

* fix: remove unused PluginState import from tools.ts

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-04-07 10:11:57 +02:00
482 changed files with 29016 additions and 7507 deletions
+11 -2
View File
@@ -157,7 +157,16 @@ If any files in `hindsight-integrations/` were added or changed, verify:
- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh`. If missing, flag it.
- **Code standards** — the integration code must follow all Python style rules (type hints, no raw dicts, no tuple returns, etc.).
### 10. Review against other coding standards
### 10. Check MCP tool registration completeness
If any new MCP tools were added or existing tools renamed in `hindsight-api-slim/hindsight_api/mcp_tools.py`:
- **`_ALL_TOOLS` set** in `mcp_tools.py` — must include the new tool name
- **`tools_to_register` default set** in `register_mcp_tools()` in `mcp_tools.py` — must include the new tool name
- **`_SINGLE_BANK_TOOLS` set** in `hindsight-api-slim/hindsight_api/api/mcp.py` — must include the new tool if it is bank-scoped (not a bank-management tool like `list_banks`/`create_bank`)
- **`MCP_TOOL_GROUPS`** in `hindsight-control-plane/src/components/bank-config-view.tsx` — must include the new tool in the appropriate group for the UI tool selector
- **Tool count assertions** in tests (e.g., `test_mcp_tools.py`) — must be updated to reflect the new count
### 11. Review against other coding standards
Check the diff for violations of the standards listed above:
- Python files at project root (not allowed)
@@ -169,7 +178,7 @@ Check the diff for violations of the standards listed above:
- Premature abstractions or speculative helpers
- Backwards-compatibility hacks (unused vars, re-exports, "removed" comments)
### 11. Report findings
### 12. Report findings
Present a clear summary organized by severity:
+58 -1
View File
@@ -150,6 +150,55 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-hindsight-all-npm:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-all-npm
- name: Build
run: npm run build --workspace=hindsight-all-npm
- name: Publish to npm
working-directory: ./hindsight-all-npm
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-all-npm
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: hindsight-all-npm
path: hindsight-all-npm/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -407,7 +456,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-hindsight-all-npm, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -436,6 +485,12 @@ jobs:
name: control-plane
path: ./artifacts/control-plane
- name: Download hindsight-embed npm wrapper
uses: actions/download-artifact@v8
with:
name: hindsight-all-npm
path: ./artifacts/hindsight-all-npm
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v8
with:
@@ -472,6 +527,8 @@ jobs:
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# hindsight-embed npm wrapper
cp artifacts/hindsight-all-npm/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+145 -47
View File
@@ -32,6 +32,7 @@ jobs:
helm: ${{ steps.filter.outputs.helm }}
docs: ${{ steps.filter.outputs.docs }}
embed: ${{ steps.filter.outputs.embed }}
all-npm: ${{ steps.filter.outputs.all-npm }}
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
integration-tests: ${{ steps.filter.outputs.integration-tests }}
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
@@ -43,9 +44,9 @@ jobs:
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
@@ -92,6 +93,10 @@ jobs:
- '*.md'
embed:
- 'hindsight-embed/**'
all-npm:
- 'hindsight-all-npm/**'
- 'package.json'
- 'package-lock.json'
hindsight-all:
- 'hindsight-all/**'
integration-tests:
@@ -114,12 +119,12 @@ jobs:
- 'hindsight-integrations/pydantic-ai/**'
integrations-ag2:
- 'hindsight-integrations/ag2/**'
integrations-hermes:
- 'hindsight-integrations/hermes/**'
integrations-llamaindex:
- 'hindsight-integrations/llamaindex/**'
integrations-paperclip:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
dev:
- 'hindsight-dev/**'
ci:
@@ -183,12 +188,12 @@ jobs:
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
build-openclaw-integration:
build-hindsight-all-npm:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.all-npm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -201,19 +206,71 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-all-npm
- name: Run tests
run: npm test --workspace=hindsight-all-npm
- name: Build
run: npm run build --workspace=hindsight-all-npm
build-openclaw-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.all-npm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
# openclaw depends on two monorepo workspaces via `file:` deps:
# @vectorize-io/hindsight-client and @vectorize-io/hindsight-all. Their
# `dist/` directories are gitignored, so we must build them first.
# Otherwise vitest/tsc in openclaw fails with
# "Failed to resolve entry for package ..." on the value imports.
- name: Install root workspace dependencies
run: npm ci
- name: Build hindsight-client (openclaw dep)
run: npm run build --workspace=hindsight-clients/typescript
- name: Build hindsight-all-npm (openclaw dep)
run: npm run build --workspace=hindsight-all-npm
- name: Install openclaw dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
# Build must run before tests: one unit test in src/backfill.test.ts
# creates a symlink to `$cwd/dist/backfill.js` and calls realpathSync on
# it via isDirectExecution(). Without a populated dist/ the realpath call
# throws, both paths stay unresolved, and the equality assertion fails.
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
- name: Run tests
working-directory: ./hindsight-integrations/openclaw
run: npm test
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
test-claude-code-integration:
needs: [detect-changes]
if: >-
@@ -329,6 +386,37 @@ jobs:
working-directory: ./hindsight-integrations/ai-sdk
run: npm run test:deno
test-opencode-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-opencode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/opencode
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/opencode
run: npm test
- name: Build
working-directory: ./hindsight-integrations/opencode
run: npm run build
build-chat-integration:
needs: [detect-changes]
if: >-
@@ -1494,6 +1582,18 @@ jobs:
print('Models downloaded successfully')
"
# openclaw depends on @vectorize-io/hindsight-client and
# @vectorize-io/hindsight-all via `file:` — their `dist/` directories are
# gitignored and must be built before openclaw's npm ci copies them.
- name: Install root workspace dependencies
run: npm ci
- name: Build hindsight-client (openclaw dep)
run: npm run build --workspace=hindsight-clients/typescript
- name: Build hindsight-all-npm (openclaw dep)
run: npm run build --workspace=hindsight-all-npm
- name: Install openclaw integration dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
@@ -1789,43 +1889,6 @@ jobs:
working-directory: ./hindsight-integrations/pydantic-ai
run: uv run pytest tests -v
test-hermes-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-hermes == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build hermes integration
working-directory: ./hindsight-integrations/hermes
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/hermes
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/hermes
run: uv run pytest tests -v
test-llamaindex-integration:
needs: [detect-changes]
if: >-
@@ -2447,6 +2510,40 @@ jobs:
cd hindsight-dev
uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json
check-cli-coverage:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.dev == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match
- name: Check CLI covers every OpenAPI operation
run: |
cd hindsight-dev
uv run cli-coverage-check
# Report CI status back to the PR for pull_request_review events.
# GitHub does not automatically link pull_request_review check runs to the PR,
# so we create a commit status on the PR head SHA and post a comment.
@@ -2461,6 +2558,7 @@ jobs:
- test-codex-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- build-chat-integration
- test-paperclip-integration
- build-control-plane
@@ -2481,7 +2579,6 @@ jobs:
- test-crewai-integration
- test-litellm-integration
- test-pydantic-ai-integration
- test-hermes-integration
- test-llamaindex-integration
- test-pip-slim
- test-embed
@@ -2490,6 +2587,7 @@ jobs:
- test-upgrade
- verify-generated-files
- check-openapi-compatibility
- check-cli-coverage
runs-on: ubuntu-latest
permissions:
statuses: write
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.22
appVersion: "0.4.22"
version: 0.5.0
appVersion: "0.5.0"
keywords:
- ai
- memory
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.tgz
.DS_Store
+80
View File
@@ -0,0 +1,80 @@
# @vectorize-io/hindsight-all
Node.js equivalent of the Python [`hindsight-all`](https://pypi.org/project/hindsight-all/) package — programmatic lifecycle manager for a local Hindsight daemon. Use this when you want to embed Hindsight in a Node application without hand-rolling subprocess management.
This package deliberately does **not** ship an HTTP client. Once the daemon is running, talk to it with [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client) against `server.getBaseUrl()`. The two packages compose — one owns the daemon process, the other owns the HTTP API surface.
## Requirements
- **Node.js >= 22** — uses global `fetch` and `AbortSignal.timeout`.
- **`uv` / `uvx`** on `PATH` — used to download and run the underlying `hindsight-embed` daemon on first use. Install via <https://docs.astral.sh/uv/>.
## Install
```bash
npm install @vectorize-io/hindsight-all @vectorize-io/hindsight-client
```
## Example
```ts
import { HindsightServer, consoleLogger } from '@vectorize-io/hindsight-all';
import { HindsightClient } from '@vectorize-io/hindsight-client';
const server = new HindsightServer({
profile: 'my-app',
port: 9077,
env: {
HINDSIGHT_API_LLM_PROVIDER: 'anthropic',
HINDSIGHT_API_LLM_API_KEY: process.env.ANTHROPIC_API_KEY,
HINDSIGHT_API_LLM_MODEL: 'claude-sonnet-4-20250514',
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: '0',
},
logger: consoleLogger,
});
await server.start();
const client = new HindsightClient({ baseUrl: server.getBaseUrl() });
await client.retain('user-123', 'User prefers dark mode and concise answers.', {
documentId: 'pref-2026-04-01',
});
const recall = await client.recall('user-123', 'what are the user preferences?');
console.log(recall.results);
await server.stop();
```
For a remote Hindsight API, skip `HindsightServer` entirely and just point `HindsightClient` at the remote URL.
## Open config — forward-compatible with new daemon flags
`HindsightServerOptions` is designed so every new environment variable or CLI flag in the underlying Hindsight daemon can be used without waiting for a wrapper release:
- **`env`** accepts an arbitrary `Record<string, string>`. Every entry is exported into the daemon process and written into the profile config via `--env KEY=VALUE`.
- **`extraProfileCreateArgs`** / **`extraDaemonStartArgs`** append raw args to the respective commands.
## Development against a local checkout
If you're hacking on the Python `hindsight-embed` package in the same monorepo, point the server at the local path — it'll use `uv run --directory <path>` instead of `uvx`:
```ts
new HindsightServer({
embedPackagePath: '/path/to/hindsight-embed',
// ...
});
```
## API surface
- `HindsightServer` — daemon lifecycle (`start`, `stop`, `checkHealth`, `getBaseUrl`, `getProfile`).
- `Logger` interface plus `silentLogger` (default) and `consoleLogger` helpers.
- `getEmbedCommand(opts)` — low-level helper that returns the `[cmd, ...args]` tuple used to invoke the underlying Python CLI.
For memory operations (retain, recall, reflect, bank management, stats) use [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client).
## License
MIT
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.5.0",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"keywords": [
"hindsight",
"hindsight-all",
"memory",
"ai",
"agent",
"long-term-memory",
"llm",
"embedded-server"
],
"author": "Vectorize <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/vectorize-io/hindsight.git",
"directory": "hindsight-all-npm"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rm -rf dist",
"test": "vitest run src",
"test:watch": "vitest src",
"prepublishOnly": "npm run clean && npm run build"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0",
"vitest": "^4.1.2"
},
"engines": {
"node": ">=22"
},
"overrides": {
"rollup": "^4.59.0",
"picomatch": ">=2.3.2 <3.0.0 || >=4.0.4",
"vite": ">=8.0.5"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { getEmbedCommand } from './command.js';
describe('getEmbedCommand', () => {
it('defaults to uvx hindsight-embed@latest', () => {
expect(getEmbedCommand()).toEqual(['uvx', 'hindsight-embed@latest']);
});
it('honours an explicit version', () => {
expect(getEmbedCommand({ embedVersion: '0.5.0' })).toEqual(['uvx', '[email protected]']);
});
it('treats an empty version as latest', () => {
expect(getEmbedCommand({ embedVersion: '' })).toEqual(['uvx', 'hindsight-embed@latest']);
});
it('uses uv run --directory when a local path is given', () => {
expect(getEmbedCommand({ embedPackagePath: '/abs/path' })).toEqual([
'uv',
'run',
'--directory',
'/abs/path',
'hindsight-embed',
]);
});
it('local path takes precedence over version', () => {
expect(
getEmbedCommand({ embedPackagePath: '/abs/path', embedVersion: '0.5.0' }),
).toEqual(['uv', 'run', '--directory', '/abs/path', 'hindsight-embed']);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* Resolve the command that invokes the `hindsight-embed` Python CLI.
*
* - If `embedPackagePath` is set, runs the package from a local checkout via
* `uv run --directory <path> hindsight-embed`. Used for in-repo development.
* - Otherwise runs it via `uvx hindsight-embed@<version>` so no global install
* is required.
*
* Returns the argv as `[command, ...baseArgs]` suitable for `spawn()` /
* `execFile()` (never shell-interpolated).
*/
export interface EmbedCommandOptions {
/** Version spec passed to uvx (e.g. "latest", "0.5.0"). Default: "latest". */
embedVersion?: string;
/** Local checkout path. When set, overrides `embedVersion` and uses `uv run`. */
embedPackagePath?: string;
}
export function getEmbedCommand(opts: EmbedCommandOptions = {}): string[] {
if (opts.embedPackagePath) {
return ['uv', 'run', '--directory', opts.embedPackagePath, 'hindsight-embed'];
}
const version = opts.embedVersion && opts.embedVersion.length > 0 ? opts.embedVersion : 'latest';
return ['uvx', `hindsight-embed@${version}`];
}
+7
View File
@@ -0,0 +1,7 @@
export { HindsightServer } from './server.js';
export { getEmbedCommand } from './command.js';
export { silentLogger, consoleLogger } from './logger.js';
export type { Logger } from './logger.js';
export type { EmbedCommandOptions } from './command.js';
export type { HindsightServerOptions } from './types.js';
+29
View File
@@ -0,0 +1,29 @@
/**
* Pluggable logger interface.
*
* This package does not own any logging infrastructure — consumers inject
* whatever they want (console, pino, openclaw's logger, a no-op). The default
* is silent so embedding this package never adds noise to an unrelated app.
*/
export interface Logger {
debug(msg: string): void;
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}
/** Logger that drops every call. Used when no logger is passed. */
export const silentLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
/** Logger that writes to the standard console. Handy for CLIs and tests. */
export const consoleLogger: Logger = {
debug: (msg) => console.debug(msg),
info: (msg) => console.log(msg),
warn: (msg) => console.warn(msg),
error: (msg) => console.error(msg),
};
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { HindsightServer } from './server.js';
describe('HindsightServer construction', () => {
it('defaults base URL to http://127.0.0.1:8888', () => {
const server = new HindsightServer();
expect(server.getBaseUrl()).toBe('http://127.0.0.1:8888');
expect(server.getProfile()).toBe('default');
});
it('honours custom profile, port, and host', () => {
const server = new HindsightServer({ profile: 'app', port: 9077, host: '0.0.0.0' });
expect(server.getProfile()).toBe('app');
expect(server.getBaseUrl()).toBe('http://0.0.0.0:9077');
});
it('accepts open env pass-through without complaining about unknown keys', () => {
const server = new HindsightServer({
env: {
HINDSIGHT_API_LLM_PROVIDER: 'openai',
HINDSIGHT_API_LLM_MODEL: 'gpt-4o-mini',
// A field that does not exist today — should still be accepted
HINDSIGHT_FUTURE_FLAG: 'enabled',
},
});
expect(server).toBeInstanceOf(HindsightServer);
});
it('exposes checkHealth that returns false when no daemon is running', async () => {
// Random high port that nothing is listening on.
const server = new HindsightServer({ port: 1, readyTimeoutMs: 100 });
const healthy = await server.checkHealth();
expect(healthy).toBe(false);
});
});
+322
View File
@@ -0,0 +1,322 @@
import { spawn } from 'child_process';
import { getEmbedCommand } from './command.js';
import { silentLogger } from './logger.js';
import type { Logger } from './logger.js';
import type { HindsightServerOptions } from './types.js';
const DEFAULT_PORT = 8888;
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PROFILE = 'default';
const DEFAULT_READY_TIMEOUT_MS = 30_000;
const DEFAULT_READY_POLL_INTERVAL_MS = 1_000;
/**
* Manages the lifecycle of a local Hindsight daemon from a Node.js process.
*
* On {@link start}, this class:
* 1. Resolves the `hindsight-embed` command (via `uvx` or a local `uv run`).
* 2. Runs `profile create <name> --merge --port <port> [--env K=V ...]`
* with every entry in {@link HindsightServerOptions.env} forwarded as
* an `--env` flag.
* 3. Runs `daemon --profile <name> start` and waits for the start command
* to exit.
* 4. Polls `http://host:port/health` until it returns `200` or the
* `readyTimeoutMs` budget is exhausted.
*
* On {@link stop}, it runs `daemon --profile <name> stop` and returns once
* the command exits (or after a short grace period).
*
* This is the Node.js equivalent of the Python `hindsight-all` package's
* `HindsightServer`: a thin programmatic lifecycle wrapper around the
* Hindsight daemon. It does NOT ship an HTTP client — once `start()`
* resolves, use `@vectorize-io/hindsight-client` against `getBaseUrl()` for
* retain / recall / reflect.
*
* The class is deliberately transparent about the daemon: new CLI flags or
* environment variables never require a code change here — callers can pass
* them via `env`, `extraProfileCreateArgs`, or `extraDaemonStartArgs`.
*/
export class HindsightServer {
private readonly profile: string;
private readonly port: number;
private readonly host: string;
private readonly baseUrl: string;
private readonly embedVersion: string | undefined;
private readonly embedPackagePath: string | undefined;
private readonly userEnv: Record<string, string | undefined>;
private readonly extraProfileCreateArgs: string[];
private readonly extraDaemonStartArgs: string[];
private readonly platformCpuWorkaround: boolean;
private readonly readyTimeoutMs: number;
private readonly readyPollIntervalMs: number;
private readonly logger: Logger;
constructor(opts: HindsightServerOptions = {}) {
this.profile = opts.profile ?? DEFAULT_PROFILE;
this.port = opts.port ?? DEFAULT_PORT;
this.host = opts.host ?? DEFAULT_HOST;
this.baseUrl = `http://${this.host}:${this.port}`;
this.embedVersion = opts.embedVersion;
this.embedPackagePath = opts.embedPackagePath;
this.userEnv = opts.env ?? {};
this.extraProfileCreateArgs = opts.extraProfileCreateArgs ?? [];
this.extraDaemonStartArgs = opts.extraDaemonStartArgs ?? [];
this.platformCpuWorkaround = opts.platformCpuWorkaround ?? (process.platform === 'darwin');
this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
this.readyPollIntervalMs = opts.readyPollIntervalMs ?? DEFAULT_READY_POLL_INTERVAL_MS;
this.logger = opts.logger ?? silentLogger;
}
/** The base URL the daemon listens on (`http://host:port`). */
getBaseUrl(): string {
return this.baseUrl;
}
/** The profile name this server operates on. */
getProfile(): string {
return this.profile;
}
/**
* Ensure the daemon is configured and running. Idempotent — the underlying
* `profile create --merge` and `daemon start` commands tolerate re-runs.
*/
async start(): Promise<void> {
this.logger.info(`[hindsight] starting daemon for profile "${this.profile}"`);
const env = this.buildEnv();
await this.configureProfile(env);
await this.startDaemon(env);
await this.waitForReady();
this.logger.info(`[hindsight] daemon ready at ${this.baseUrl}`);
}
/** Stop the daemon. Never throws — logs and resolves even on failure. */
async stop(): Promise<void> {
this.logger.info(`[hindsight] stopping daemon for profile "${this.profile}"`);
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const args = [...baseArgs, 'daemon', '--profile', this.profile, 'stop'];
const child = spawn(cmd, args, { stdio: 'pipe' });
this.pipeOutput(child, 'daemon.stop');
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
this.logger.warn(`[hindsight] daemon stop timed out after 5s`);
resolve();
}, 5_000);
child.on('exit', () => {
clearTimeout(timeout);
this.logger.info(`[hindsight] daemon stopped`);
resolve();
});
child.on('error', (err) => {
clearTimeout(timeout);
this.logger.warn(`[hindsight] error stopping daemon: ${err.message}`);
resolve();
});
});
}
/** Probe `/health` once with a short timeout. */
async checkHealth(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(2_000),
});
return res.ok;
} catch {
return false;
}
}
// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------
/**
* Merge the process env, the caller-supplied `env`, and (on macOS) the
* embeddings CPU workaround. Caller-supplied values always win over the
* workaround; undefined values are dropped.
*/
private buildEnv(): NodeJS.ProcessEnv {
const merged: NodeJS.ProcessEnv = { ...process.env };
if (this.platformCpuWorkaround && process.platform === 'darwin') {
merged['HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU'] = '1';
merged['HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
}
for (const [key, value] of Object.entries(this.userEnv)) {
if (value !== undefined) {
merged[key] = value;
}
}
return merged;
}
/**
* Run `profile create <name> --merge --port <port> [--env K=V ...]`.
* Every entry in the merged env that was passed via {@link userEnv} (or
* auto-applied by the CPU workaround) is forwarded as `--env`.
*/
private async configureProfile(env: NodeJS.ProcessEnv): Promise<void> {
this.logger.info(`[hindsight] configuring profile "${this.profile}"`);
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const createArgs = [
...baseArgs,
'profile',
'create',
this.profile,
'--merge',
'--port',
String(this.port),
];
// Forward every env var that the caller intended for the daemon as --env.
// We only forward keys the caller explicitly set (userEnv) plus the CPU
// workaround values — not the entire process.env, to avoid leaking random
// host state into profile config.
const envForProfile = this.collectProfileEnv(env);
for (const [key, value] of Object.entries(envForProfile)) {
createArgs.push('--env', `${key}=${value}`);
}
createArgs.push(...this.extraProfileCreateArgs);
await this.runCommand(cmd, createArgs, env, 'profile.create');
}
/** Collect only the env vars that should be written into the profile file. */
private collectProfileEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const out: Record<string, string> = {};
// 1. User-supplied env — always forwarded.
for (const [key, value] of Object.entries(this.userEnv)) {
if (value !== undefined) {
out[key] = value;
}
}
// 2. CPU workaround — only if auto-applied and not already overridden.
if (this.platformCpuWorkaround && process.platform === 'darwin') {
const cpuKeys = [
'HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU',
'HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU',
];
for (const key of cpuKeys) {
if (!(key in out) && env[key] !== undefined) {
out[key] = env[key] as string;
}
}
}
return out;
}
private async startDaemon(env: NodeJS.ProcessEnv): Promise<void> {
const [cmd, ...baseArgs] = getEmbedCommand({
embedVersion: this.embedVersion,
embedPackagePath: this.embedPackagePath,
});
const args = [
...baseArgs,
'daemon',
'--profile',
this.profile,
'start',
...this.extraDaemonStartArgs,
];
await this.runCommand(cmd, args, env, 'daemon.start');
}
/**
* Spawn `cmd` with `args`, pipe its output through the logger, and resolve
* once it exits with code 0. Rejects on non-zero exit or spawn error.
*/
private async runCommand(
cmd: string,
args: string[],
env: NodeJS.ProcessEnv,
label: string,
): Promise<void> {
const child = spawn(cmd, args, { stdio: 'pipe', env });
let output = '';
child.stdout?.on('data', (data: Buffer) => {
const text = data.toString();
output += text;
for (const line of text.trimEnd().split('\n')) {
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
}
});
child.stderr?.on('data', (data: Buffer) => {
const text = data.toString();
output += text;
for (const line of text.trimEnd().split('\n')) {
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
}
});
await new Promise<void>((resolve, reject) => {
child.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`${label} failed with code ${code}: ${output.trim()}`));
}
});
child.on('error', (err) => {
reject(new Error(`${label} failed to spawn: ${err.message}`, { cause: err }));
});
});
}
/** Stream a spawned child's stdout/stderr through the logger without blocking. */
private pipeOutput(child: ReturnType<typeof spawn>, label: string): void {
child.stdout?.on('data', (data: Buffer) => {
for (const line of data.toString().trimEnd().split('\n')) {
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
}
});
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().trimEnd().split('\n')) {
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
}
});
}
/** Poll `/health` until it succeeds or `readyTimeoutMs` elapses. */
private async waitForReady(): Promise<void> {
const deadline = Date.now() + this.readyTimeoutMs;
let attempt = 0;
while (Date.now() < deadline) {
attempt++;
try {
const res = await fetch(`${this.baseUrl}/health`, {
signal: AbortSignal.timeout(this.readyPollIntervalMs),
});
if (res.ok) {
this.logger.debug(`[hindsight] health check passed (attempt ${attempt})`);
return;
}
} catch {
// expected while the daemon is still booting
}
await new Promise((resolve) => setTimeout(resolve, this.readyPollIntervalMs));
}
throw new Error(
`Hindsight daemon did not become ready within ${this.readyTimeoutMs}ms at ${this.baseUrl}`,
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { Logger } from './logger.js';
/**
* Options for {@link HindsightServer}.
*
* The server is intentionally thin and pass-through: anything configurable
* on the daemon side (env vars or CLI flags) can be set here without needing
* a new dedicated option. Use {@link env} for `HINDSIGHT_*` / `OPENAI_API_KEY` /
* custom provider settings, and the two `extra*` arrays to append raw CLI
* args to `profile create` or `daemon start`.
*
* For talking to the daemon after `start()`, use `@vectorize-io/hindsight-client`
* against `server.getBaseUrl()`. This package does not ship its own HTTP
* client.
*/
export interface HindsightServerOptions {
/** Profile name used for `--profile <name>` on every sub-command. Default: `"default"`. */
profile?: string;
/** TCP port the daemon listens on. Default: `8888`. */
port?: number;
/** Hostname the daemon binds to (for health checks). Default: `127.0.0.1`. */
host?: string;
/** Version of the underlying `hindsight-embed` PyPI package to run via `uvx`. Default: `"latest"`. */
embedVersion?: string;
/** Local path to a `hindsight-embed` checkout — takes precedence over `embedVersion`. */
embedPackagePath?: string;
/**
* Environment variables passed to the daemon process AND written into the
* profile via repeated `--env KEY=VALUE` flags. This is the preferred way
* to surface any `HINDSIGHT_API_*` / `HINDSIGHT_EMBED_*` setting — adding a
* new daemon env var never requires a wrapper update.
*
* Values of `undefined` are dropped (so you can spread conditionally).
*/
env?: Record<string, string | undefined>;
/** Extra args appended verbatim to `hindsight-embed profile create <name> --merge ...`. */
extraProfileCreateArgs?: string[];
/** Extra args appended verbatim to `hindsight-embed daemon --profile <name> start ...`. */
extraDaemonStartArgs?: string[];
/**
* On macOS, automatically set
* `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=1` and
* `HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1` to avoid Metal/MPS crashes in
* daemon mode. Default: `true` on `darwin`, ignored elsewhere. Any value set
* explicitly in {@link env} wins over the auto-applied value.
*/
platformCpuWorkaround?: boolean;
/** Max time (ms) to wait for `/health` to return 200. Default: `30_000`. */
readyTimeoutMs?: number;
/** Polling interval (ms) while waiting for `/health`. Default: `1_000`. */
readyPollIntervalMs?: number;
/** Optional pluggable logger. Default: silent. */
logger?: Logger;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
outDir: 'dist',
clean: true,
sourcemap: true,
bundle: true,
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.4.22"
version = "0.5.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.4.22"
version = "0.5.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
@@ -20,6 +20,9 @@ hindsight-client = { workspace = true }
hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]>=0.4.17",
]
test = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.22"
__version__ = "0.5.0"
@@ -0,0 +1,42 @@
"""Merge 3 migration heads and add unit_entities composite index
Revision ID: h3i4j5k6l7m8
Revises: a4b5c6d7e8f9, c2d3e4f5g6h7, g2h3i4j5k6l7
Create Date: 2026-04-07
Merges three unmerged migration heads into one, and adds a composite index
(entity_id, unit_id) on unit_entities for index-only scans in the LATERAL
entity expansion query.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "h3i4j5k6l7m8"
down_revision: str | Sequence[str] | None = ("a4b5c6d7e8f9", "c2d3e4f5g6h7", "g2h3i4j5k6l7")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Composite index enables index-only scans for entity_id -> unit_id lookups
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity_unit ON {schema}unit_entities (entity_id, unit_id)"
)
# Drop the now-redundant single-column index
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity_unit")
# Restore the single-column index
op.execute(f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity ON {schema}unit_entities (entity_id)")
+19 -1
View File
@@ -463,6 +463,12 @@ class MemoryItem(BaseModel):
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
"Strategies are defined in the bank config under 'retain_strategies'.",
)
update_mode: Literal["replace", "append"] | None = Field(
default=None,
description="How to handle an existing document with the same document_id. "
"'replace' (default) deletes old data and reprocesses from scratch. "
"'append' concatenates new content to the existing document text and reprocesses.",
)
@field_validator("timestamp", mode="before")
@classmethod
@@ -1661,7 +1667,9 @@ class BankTemplateConfig(BaseModel):
disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)")
disposition_literalism: int | None = Field(default=None, ge=1, le=5, description="Literalism trait (1-5)")
disposition_empathy: int | None = Field(default=None, ge=1, le=5, description="Empathy trait (1-5)")
entity_labels: list[str] | None = Field(default=None, description="Controlled vocabulary for entity labels")
entity_labels: list[dict[str, Any]] | None = Field(
default=None, description="Controlled vocabulary for entity labels"
)
entities_allow_free_form: bool | None = Field(
default=None, description="Allow entities outside the label vocabulary"
)
@@ -2677,6 +2685,8 @@ def _register_routes(app: FastAPI):
return data
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -3287,6 +3297,8 @@ def _register_routes(app: FastAPI):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import traceback
@@ -3320,6 +3332,8 @@ def _register_routes(app: FastAPI):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
import traceback
@@ -3484,6 +3498,8 @@ def _register_routes(app: FastAPI):
return {"status": "deleted"}
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -5297,6 +5313,8 @@ def _register_routes(app: FastAPI):
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
content_dict["observation_scopes"] = item.observation_scopes
if item.update_mode is not None:
content_dict["update_mode"] = item.update_mode
strategy_groups[effective].append(content_dict)
if request.async_:
@@ -97,6 +97,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
_SINGLE_BANK_TOOLS: frozenset[str] = frozenset(
{
"retain",
"sync_retain",
"recall",
"reflect",
"list_mental_models",
@@ -194,6 +194,13 @@ ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
# OpenRouter configuration (embeddings and reranker)
ENV_OPENROUTER_API_KEY = "HINDSIGHT_API_OPENROUTER_API_KEY"
ENV_EMBEDDINGS_OPENROUTER_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY"
ENV_EMBEDDINGS_OPENROUTER_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
@@ -211,6 +218,7 @@ ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_K
ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS"
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT"
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
@@ -262,6 +270,8 @@ ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
ENV_LINK_EXPANSION_PER_ENTITY_LIMIT = "HINDSIGHT_API_LINK_EXPANSION_PER_ENTITY_LIMIT"
ENV_LINK_EXPANSION_TIMEOUT = "HINDSIGHT_API_LINK_EXPANSION_TIMEOUT"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
@@ -334,6 +344,14 @@ ENV_WEBHOOK_SECRET = "HINDSIGHT_API_WEBHOOK_SECRET"
ENV_WEBHOOK_EVENT_TYPES = "HINDSIGHT_API_WEBHOOK_EVENT_TYPES"
ENV_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS"
# Built-in llama.cpp configuration (for provider=llamacpp)
ENV_LLAMACPP_MODEL_PATH = "HINDSIGHT_API_LLAMACPP_MODEL_PATH"
ENV_LLAMACPP_GPU_LAYERS = "HINDSIGHT_API_LLAMACPP_GPU_LAYERS"
ENV_LLAMACPP_CONTEXT_SIZE = "HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE"
ENV_LLAMACPP_CHAT_FORMAT = "HINDSIGHT_API_LLAMACPP_CHAT_FORMAT"
ENV_LLAMACPP_NO_GRAMMAR = "HINDSIGHT_API_LLAMACPP_NO_GRAMMAR"
ENV_LLAMACPP_EXTRA_ARGS = "HINDSIGHT_API_LLAMACPP_EXTRA_ARGS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
@@ -387,6 +405,7 @@ PROVIDER_DEFAULT_MODELS = {
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"ollama": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
"openai-codex": "gpt-5.2-codex",
@@ -396,8 +415,16 @@ PROVIDER_DEFAULT_MODELS = {
"litellm": "gpt-4o-mini",
"bedrock": "us.amazon.nova-2-lite-v1:0",
"volcano": "doubao-pro-32k",
"openrouter": "qwen/qwen3.5-9b",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
# Built-in llama.cpp defaults
DEFAULT_LLAMACPP_GPU_LAYERS = -1 # -1 = offload all layers to GPU (Metal/CUDA)
DEFAULT_LLAMACPP_CONTEXT_SIZE = 8192
DEFAULT_LLAMACPP_CHAT_FORMAT = None # None = auto-detect from GGUF metadata
DEFAULT_LLAMACPP_NO_GRAMMAR = False # True = disable JSON grammar enforcement (faster but less reliable)
DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.cpp server
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
@@ -440,6 +467,10 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
# OpenRouter defaults
DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
DEFAULT_RERANKER_ZEROENTROPY_MODEL = "zerank-2"
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
@@ -458,6 +489,7 @@ DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC: int | None = None
# LiteLLM SDK defaults
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "float"
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
DEFAULT_HOST = "0.0.0.0"
@@ -475,6 +507,8 @@ DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worke
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 200 # Max target units per entity in graph expansion
DEFAULT_LINK_EXPANSION_TIMEOUT = 10.0 # Timeout (seconds) for entity expansion query
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
@@ -677,6 +711,14 @@ class HindsightConfig:
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
llm_gemini_safety_settings: list | None
# Built-in llama.cpp configuration (for provider=llamacpp)
llamacpp_model_path: str | None # Path to GGUF file (None = auto-download default)
llamacpp_gpu_layers: int # -1 = all layers on GPU, 0 = CPU only
llamacpp_context_size: int # Context window size
llamacpp_chat_format: str | None # Chat template format (None = auto-detect from GGUF)
llamacpp_no_grammar: bool # Disable JSON grammar enforcement (faster, less reliable)
llamacpp_extra_args: str | None # Space-separated extra CLI args for llama.cpp server
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
@@ -718,6 +760,8 @@ class HindsightConfig:
embeddings_cohere_api_key: str | None
embeddings_cohere_model: str
embeddings_cohere_base_url: str | None
embeddings_openrouter_api_key: str | None
embeddings_openrouter_model: str
embeddings_litellm_api_base: str
embeddings_litellm_api_key: str | None
embeddings_litellm_model: str
@@ -725,6 +769,7 @@ class HindsightConfig:
embeddings_litellm_sdk_model: str
embeddings_litellm_sdk_api_base: str | None
embeddings_litellm_sdk_output_dimensions: int | None
embeddings_litellm_sdk_encoding_format: str | None
# Gemini/Vertex AI embeddings
embeddings_gemini_api_key: str | None
embeddings_gemini_model: str
@@ -749,6 +794,8 @@ class HindsightConfig:
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
reranker_openrouter_api_key: str | None
reranker_openrouter_model: str
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
reranker_litellm_model: str
@@ -780,6 +827,8 @@ class HindsightConfig:
recall_connection_budget: int
recall_max_query_tokens: int
mental_model_refresh_concurrency: int
link_expansion_per_entity_limit: int
link_expansion_timeout: float
# Retain settings
retain_max_completion_tokens: int
@@ -1092,6 +1141,14 @@ class HindsightConfig:
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
# Built-in llama.cpp configuration
llamacpp_model_path=os.getenv(ENV_LLAMACPP_MODEL_PATH) or None,
llamacpp_gpu_layers=int(os.getenv(ENV_LLAMACPP_GPU_LAYERS, str(DEFAULT_LLAMACPP_GPU_LAYERS))),
llamacpp_context_size=int(os.getenv(ENV_LLAMACPP_CONTEXT_SIZE, str(DEFAULT_LLAMACPP_CONTEXT_SIZE))),
llamacpp_chat_format=os.getenv(ENV_LLAMACPP_CHAT_FORMAT) or DEFAULT_LLAMACPP_CHAT_FORMAT,
llamacpp_no_grammar=os.getenv(ENV_LLAMACPP_NO_GRAMMAR, str(DEFAULT_LLAMACPP_NO_GRAMMAR)).lower()
in ("true", "1"),
llamacpp_extra_args=os.getenv(ENV_LLAMACPP_EXTRA_ARGS) or DEFAULT_LLAMACPP_EXTRA_ARGS,
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
@@ -1180,6 +1237,11 @@ class HindsightConfig:
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
# OpenRouter embeddings (with fallback to shared OpenRouter key, then LLM key)
embeddings_openrouter_api_key=os.getenv(ENV_EMBEDDINGS_OPENROUTER_API_KEY)
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
embeddings_openrouter_model=os.getenv(ENV_EMBEDDINGS_OPENROUTER_MODEL, DEFAULT_EMBEDDINGS_OPENROUTER_MODEL),
# LiteLLM embeddings (with backward-compatible fallback to shared config)
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
@@ -1194,6 +1256,9 @@ class HindsightConfig:
embeddings_litellm_sdk_output_dimensions=int(v)
if (v := os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS))
else None,
embeddings_litellm_sdk_encoding_format=os.getenv(
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT, DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT
),
# Gemini/Vertex AI embeddings (with fallback to LLM keys)
embeddings_gemini_api_key=os.getenv(ENV_EMBEDDINGS_GEMINI_API_KEY) or os.getenv(ENV_LLM_API_KEY),
embeddings_gemini_model=os.getenv(ENV_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_GEMINI_MODEL),
@@ -1241,6 +1306,11 @@ class HindsightConfig:
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
# OpenRouter reranker (with fallback to shared OpenRouter key, then LLM key)
reranker_openrouter_api_key=os.getenv(ENV_RERANKER_OPENROUTER_API_KEY)
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
reranker_openrouter_model=os.getenv(ENV_RERANKER_OPENROUTER_MODEL, DEFAULT_RERANKER_OPENROUTER_MODEL),
# LiteLLM reranker (with backward-compatible fallback to shared config)
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
@@ -1286,6 +1356,10 @@ class HindsightConfig:
mental_model_refresh_concurrency=int(
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
),
link_expansion_per_entity_limit=int(
os.getenv(ENV_LINK_EXPANSION_PER_ENTITY_LIMIT, str(DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT))
),
link_expansion_timeout=float(os.getenv(ENV_LINK_EXPANSION_TIMEOUT, str(DEFAULT_LINK_EXPANSION_TIMEOUT))),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
@@ -239,6 +239,15 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate entity_labels structure
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
from .engine.retain.entity_labels import parse_entity_labels
try:
parse_entity_labels(normalized_updates["entity_labels"])
except Exception as e:
raise ValueError(f"Invalid entity_labels format: {e}")
# Validate retain_strategies: reject empty string keys
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
@@ -1468,6 +1468,18 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
)
elif provider == "openrouter":
api_key = config.reranker_openrouter_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
f"or HINDSIGHT_API_LLM_API_KEY is required when {ENV_RERANKER_PROVIDER} is 'openrouter'"
)
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url="https://openrouter.ai/api/v1/rerank",
)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
@@ -757,6 +757,7 @@ class LiteLLMSDKEmbeddings(Embeddings):
output_dimensions: int | None = None,
batch_size: int = 100,
timeout: float = 60.0,
encoding_format: str | None = "float",
):
"""
Initialize LiteLLM SDK embeddings client.
@@ -768,6 +769,8 @@ class LiteLLMSDKEmbeddings(Embeddings):
output_dimensions: Optional output embedding dimensions (provider-dependent)
batch_size: Maximum batch size for embedding requests (default: 100)
timeout: Request timeout in seconds (default: 60.0)
encoding_format: Encoding format for embeddings (default: "float").
Set to None or empty string to omit (needed for Voyage AI, Gemini).
"""
self.api_key = api_key
self.model = model
@@ -775,6 +778,7 @@ class LiteLLMSDKEmbeddings(Embeddings):
self.output_dimensions = output_dimensions
self.batch_size = batch_size
self.timeout = timeout
self.encoding_format = encoding_format or None
self._litellm = None # Will be set during initialization
self._dimension: int | None = None
@@ -810,8 +814,9 @@ class LiteLLMSDKEmbeddings(Embeddings):
"model": self.model,
"input": ["test"],
"api_key": self.api_key,
"encoding_format": "float",
}
if self.encoding_format:
embed_kwargs["encoding_format"] = self.encoding_format
if self.api_base:
embed_kwargs["api_base"] = self.api_base
if self.output_dimensions is not None:
@@ -859,8 +864,9 @@ class LiteLLMSDKEmbeddings(Embeddings):
"model": self.model,
"input": batch,
"api_key": self.api_key,
"encoding_format": "float",
}
if self.encoding_format:
embed_kwargs["encoding_format"] = self.encoding_format
if self.api_base:
embed_kwargs["api_base"] = self.api_base
if self.output_dimensions is not None:
@@ -1095,6 +1101,18 @@ def create_embeddings_from_env() -> Embeddings:
model = os.environ.get(ENV_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_OPENAI_MODEL)
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "openrouter":
api_key = config.embeddings_openrouter_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
f"or {ENV_LLM_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'openrouter'"
)
return OpenAIEmbeddings(
api_key=api_key,
model=config.embeddings_openrouter_model,
base_url="https://openrouter.ai/api/v1",
)
elif provider == "cohere":
api_key = config.embeddings_cohere_api_key
if not api_key:
@@ -1121,6 +1139,7 @@ def create_embeddings_from_env() -> Embeddings:
model=config.embeddings_litellm_sdk_model,
api_base=config.embeddings_litellm_sdk_api_base,
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
encoding_format=config.embeddings_litellm_sdk_encoding_format,
)
elif provider == "google":
vertexai_project_id = config.embeddings_vertexai_project_id
@@ -122,6 +122,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
{
"ollama",
"lmstudio",
"llamacpp",
"openai-codex",
"claude-code",
"mock",
@@ -178,6 +179,7 @@ def create_llm_provider(
CodexLLM,
GeminiLLM,
LiteLLMLLM,
LlamaCppLLM,
MockLLM,
NoneLLM,
OpenAICompatibleLLM,
@@ -263,7 +265,25 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "volcano"):
elif provider_lower == "llamacpp":
from ..config import get_config
config = get_config()
return LlamaCppLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
model_path=config.llamacpp_model_path,
gpu_layers=config.llamacpp_gpu_layers,
context_size=config.llamacpp_context_size,
chat_format=config.llamacpp_chat_format,
no_grammar=config.llamacpp_no_grammar,
extra_args=config.llamacpp_extra_args,
)
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "volcano", "openrouter"):
return OpenAICompatibleLLM(
provider=provider,
api_key=api_key,
@@ -333,6 +353,7 @@ class LLMProvider:
"gemini",
"anthropic",
"lmstudio",
"llamacpp",
"vertexai",
"openai-codex",
"claude-code",
@@ -342,6 +363,7 @@ class LLMProvider:
"litellm",
"bedrock",
"volcano",
"openrouter",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -356,6 +378,8 @@ class LLMProvider:
self.base_url = "http://localhost:1234/v1"
elif self.provider == "minimax":
self.base_url = "https://api.minimax.io/v1"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -711,8 +735,9 @@ class LLMProvider:
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings)
async def cleanup(self) -> None:
"""Clean up resources."""
pass
"""Clean up resources (e.g. stop llamacpp subprocess)."""
if self._provider_impl:
await self._provider_impl.cleanup()
@classmethod
def from_env(cls) -> "LLMProvider":
@@ -1923,6 +1923,18 @@ class MemoryEngine(MemoryEngineInterface):
self._initialized = False
# Clean up LLM providers (e.g. stop llamacpp subprocess)
for llm_config in (
self._llm_config,
self._retain_llm_config,
self._reflect_llm_config,
self._consolidation_llm_config,
):
try:
await llm_config.cleanup()
except Exception as e:
logger.warning(f"Error cleaning up LLM provider: {e}")
# Stop pg0 if we started it
if self._pg0 is not None:
logger.info("Stopping pg0...")
@@ -2146,6 +2158,11 @@ class MemoryEngine(MemoryEngineInterface):
f"Each content item in a batch must have a unique document_id to avoid race conditions."
)
# Validate update_mode=append requires document_id
for item in contents:
if item.get("update_mode") == "append" and not item.get("document_id"):
raise ValueError("update_mode='append' requires a document_id")
# Auto-chunk large batches by token count to avoid timeouts and memory issues
# Calculate total token count
total_tokens = sum(count_tokens(item.get("content", "")) for item in contents)
@@ -3792,7 +3809,14 @@ class MemoryEngine(MemoryEngineInterface):
Returns:
Dictionary with deletion result
Raises:
ValueError: If unit_id is not a valid UUID
"""
try:
unit_uuid = uuid.UUID(unit_id)
except ValueError:
raise ValueError(f"Invalid unit_id: '{unit_id}' is not a valid UUID")
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
invalidated_obs = 0
@@ -3802,7 +3826,7 @@ class MemoryEngine(MemoryEngineInterface):
# Get bank_id and fact_type before deletion
row = await conn.fetchrow(
f"SELECT bank_id, fact_type FROM {fq_table('memory_units')} WHERE id = $1",
unit_id,
str(unit_uuid),
)
bank_id = row["bank_id"] if row else None
fact_type = row["fact_type"] if row else None
@@ -4697,7 +4721,14 @@ class MemoryEngine(MemoryEngineInterface):
Returns:
Dict with memory unit data or None if not found
Raises:
ValueError: If memory_id is not a valid UUID
"""
try:
memory_uuid = uuid.UUID(memory_id)
except ValueError:
raise ValueError(f"Invalid memory_id: '{memory_id}' is not a valid UUID")
await self._authenticate_tenant(request_context)
if self._operation_validator:
from hindsight_api.extensions import BankReadContext
@@ -4715,7 +4746,7 @@ class MemoryEngine(MemoryEngineInterface):
FROM {fq_table("memory_units")}
WHERE id = $1 AND bank_id = $2
""",
memory_id,
str(memory_uuid),
bank_id,
)
@@ -6497,6 +6528,7 @@ class MemoryEngine(MemoryEngineInterface):
Returns None if the mental model is not found.
Returns a list of history entries (most recent first), each with previous_content and changed_at.
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
@@ -9,6 +9,7 @@ from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM
from .llamacpp_llm import LlamaCppLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
from .openai_compatible_llm import OpenAICompatibleLLM
@@ -18,6 +19,7 @@ __all__ = [
"ClaudeCodeLLM",
"CodexLLM",
"GeminiLLM",
"LlamaCppLLM",
"LiteLLMLLM",
"MockLLM",
"NoneLLM",
@@ -0,0 +1,428 @@
"""
Built-in llama.cpp LLM provider for fully offline operation.
Manages a llama-cpp-python server as a subprocess, downloads GGUF models
from HuggingFace on first use, and delegates inference to the OpenAI-compatible API.
Usage:
HINDSIGHT_API_LLM_PROVIDER=llamacpp
HINDSIGHT_API_LLAMACPP_MODEL_PATH=~/.hindsight/models/gemma-4-E2B-it-Q4_K_M.gguf
HINDSIGHT_API_LLAMACPP_GPU_LAYERS=-1 # -1 = all layers on GPU
HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE=8192
"""
import asyncio
import logging
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
# Default GGUF model for offline mode
DEFAULT_LLAMACPP_HF_REPO = "bartowski/google_gemma-4-E2B-it-GGUF"
DEFAULT_LLAMACPP_HF_FILENAME = "google_gemma-4-E2B-it-Q4_K_M.gguf"
DEFAULT_LLAMACPP_MODEL_ALIAS = "gemma-4-e2b-it"
MODELS_DIR = Path.home() / ".hindsight" / "models"
# Singleton server instance — shared across all LlamaCppLLM instances
# (retain, reflect, consolidation each create their own LLMProvider,
# but they should all share one llama.cpp server process)
_shared_server: "LlamaCppServer | None" = None
_shared_server_lock = asyncio.Lock()
def _find_free_port() -> int:
"""Find a free TCP port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _download_default_model() -> Path:
"""Download the default GGUF model from HuggingFace if not already cached.
Returns:
Path to the downloaded GGUF file.
"""
try:
from huggingface_hub import hf_hub_download
except ImportError:
raise ImportError(
"huggingface-hub is required for automatic model download. "
"Install with: pip install 'hindsight-api-slim[local-llm]'"
)
MODELS_DIR.mkdir(parents=True, exist_ok=True)
target = MODELS_DIR / DEFAULT_LLAMACPP_HF_FILENAME
if target.exists():
logger.info(f"Using cached model: {target}")
return target
logger.info(
f"Downloading {DEFAULT_LLAMACPP_HF_FILENAME} from {DEFAULT_LLAMACPP_HF_REPO} (~3.5 GB, first run only)..."
)
downloaded = hf_hub_download(
repo_id=DEFAULT_LLAMACPP_HF_REPO,
filename=DEFAULT_LLAMACPP_HF_FILENAME,
local_dir=str(MODELS_DIR),
)
logger.info(f"Model downloaded: {downloaded}")
return Path(downloaded)
def _resolve_model_path(model_path: str | None) -> Path:
"""Resolve the model path, downloading the default if needed.
Args:
model_path: Explicit path to a GGUF file, or None to use the default.
Returns:
Resolved Path to the GGUF file.
"""
if model_path:
p = Path(model_path).expanduser()
if not p.exists():
raise FileNotFoundError(
f"GGUF model not found: {p}\n"
f"Set HINDSIGHT_API_LLAMACPP_MODEL_PATH to a valid .gguf file, "
f"or remove the setting to auto-download the default model."
)
return p
return _download_default_model()
class LlamaCppServer:
"""Manages a llama-cpp-python OpenAI-compatible server as a subprocess."""
def __init__(
self,
model_path: Path,
port: int,
gpu_layers: int = -1,
context_size: int = 8192,
chat_format: str | None = None,
extra_args: str | None = None,
):
self.model_path = model_path
self.port = port
self.gpu_layers = gpu_layers
self.context_size = context_size
self.chat_format = chat_format
self.extra_args = extra_args
self._process: subprocess.Popen | None = None
@property
def base_url(self) -> str:
return f"http://127.0.0.1:{self.port}/v1"
async def start(self) -> None:
"""Start the llama.cpp server subprocess."""
cmd = [
sys.executable,
"-m",
"llama_cpp.server",
"--model",
str(self.model_path),
"--host",
"127.0.0.1",
"--port",
str(self.port),
"--n_gpu_layers",
str(self.gpu_layers),
"--n_ctx",
str(self.context_size),
"--flash_attn",
"true",
"--n_batch",
"2048",
# Prompt cache: reuse KV cache for repeated system prompts
"--cache",
"true",
]
# Only pass chat_format if explicitly set (most GGUF models have it embedded)
if self.chat_format:
cmd.extend(["--chat_format", self.chat_format])
# User-provided extra args (e.g. "--type_k 1 --type_v 1 --n_threads 8")
if self.extra_args:
cmd.extend(self.extra_args.split())
logger.info(f"Starting llama.cpp server: {' '.join(cmd)}")
# Write stderr to a log file to avoid pipe buffer deadlock
# (llama.cpp outputs a lot of model metadata on stderr during loading)
self._log_path = MODELS_DIR / "llamacpp_server.log"
self._log_file = open(self._log_path, "w")
self._process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=self._log_file,
# Ensure the subprocess is killed when the parent exits
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
)
# Wait for the server to be ready
await self._wait_for_ready()
async def _wait_for_ready(self, timeout: float = 120.0) -> None:
"""Wait for the llama.cpp server to accept connections."""
import httpx
start = time.monotonic()
url = f"http://127.0.0.1:{self.port}/v1/models"
last_log = start
while time.monotonic() - start < timeout:
# Check if process died
if self._process and self._process.poll() is not None:
stderr = ""
try:
stderr = self._log_path.read_text()[-2000:]
except Exception:
pass
raise RuntimeError(f"llama.cpp server exited with code {self._process.returncode}.\nstderr: {stderr}")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=5.0)
if resp.status_code == 200:
logger.info(f"llama.cpp server ready on port {self.port}")
return
except (httpx.ConnectError, httpx.TimeoutException, httpx.ConnectTimeout):
pass
# Log progress every 15s
now = time.monotonic()
if now - last_log > 15:
elapsed = int(now - start)
logger.info(f"Waiting for llama.cpp server to load model... ({elapsed}s)")
last_log = now
await asyncio.sleep(1.0)
# Timeout — read the log to help debug
stderr = ""
try:
stderr = self._log_path.read_text()[-2000:]
except Exception:
pass
raise TimeoutError(
f"llama.cpp server did not become ready within {timeout}s.\n"
f"Check model compatibility and available memory.\n"
f"Server log: {stderr}"
)
async def stop(self) -> None:
"""Stop the llama.cpp server subprocess."""
if self._process is None:
return
logger.info("Stopping llama.cpp server...")
try:
# Send SIGTERM to the process group
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
else:
self._process.terminate()
# Wait up to 10s for graceful shutdown
try:
self._process.wait(timeout=10)
except subprocess.TimeoutExpired:
if hasattr(os, "killpg"):
os.killpg(os.getpgid(self._process.pid), signal.SIGKILL)
else:
self._process.kill()
self._process.wait(timeout=5)
except (ProcessLookupError, OSError):
pass # Process already exited
finally:
self._process = None
if hasattr(self, "_log_file") and self._log_file:
self._log_file.close()
self._log_file = None
logger.info("llama.cpp server stopped")
class LlamaCppLLM(LLMInterface):
"""
Built-in llama.cpp provider.
Manages a llama-cpp-python server subprocess and delegates to OpenAICompatibleLLM
for actual inference calls. Handles model downloading and server lifecycle.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
model_path: str | None = None,
gpu_layers: int = -1,
context_size: int = 8192,
chat_format: str | None = None,
no_grammar: bool = False,
extra_args: str | None = None,
**kwargs: Any,
):
super().__init__(
provider=provider,
api_key=api_key or "llamacpp",
base_url=base_url or "",
model=model or DEFAULT_LLAMACPP_MODEL_ALIAS,
reasoning_effort=reasoning_effort,
)
self._model_path_str = model_path
self._gpu_layers = gpu_layers
self._context_size = context_size
self._chat_format = chat_format
self._no_grammar = no_grammar
self._extra_args = extra_args
self._server: LlamaCppServer | None = None
self._delegate: Any = None # OpenAICompatibleLLM, created after server starts
self._initialized = False
async def _ensure_initialized(self) -> None:
"""Lazy initialization: download model + start shared server on first use."""
if self._initialized:
return
global _shared_server
from .openai_compatible_llm import OpenAICompatibleLLM
async with _shared_server_lock:
if _shared_server is None:
# Resolve and potentially download the model
model_path = _resolve_model_path(self._model_path_str)
logger.info(f"Using GGUF model: {model_path}")
# Start the shared llama.cpp server
port = _find_free_port()
_shared_server = LlamaCppServer(
model_path=model_path,
port=port,
gpu_layers=self._gpu_layers,
context_size=self._context_size,
chat_format=self._chat_format,
extra_args=self._extra_args,
)
await _shared_server.start()
self._server = _shared_server
# Create the delegate that talks to the shared server's OpenAI-compatible API
if self._no_grammar:
logger.info("Grammar enforcement disabled (HINDSIGHT_API_LLAMACPP_NO_GRAMMAR=true)")
self._delegate = OpenAICompatibleLLM(
provider="llamacpp",
api_key="llamacpp",
base_url=self._server.base_url,
model=self.model,
reasoning_effort=self.reasoning_effort,
)
self._initialized = True
async def verify_connection(self) -> None:
"""Verify the llama.cpp server is running and can generate text."""
await self._ensure_initialized()
# Make a simple test call to verify the model can actually generate
await self._delegate.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=10,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info("llama.cpp LLM verification passed")
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Delegate call to the OpenAI-compatible API."""
await self._ensure_initialized()
return await self._delegate.call(
messages=messages,
response_format=response_format,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=skip_validation,
strict_schema=strict_schema,
return_usage=return_usage,
)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""Delegate tool calls to the OpenAI-compatible API."""
await self._ensure_initialized()
return await self._delegate.call_with_tools(
messages=messages,
tools=tools,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
tool_choice=tool_choice,
)
async def cleanup(self) -> None:
"""Stop the shared llama.cpp server."""
global _shared_server
if self._delegate:
await self._delegate.cleanup()
self._delegate = None
# Stop the shared server (only the first cleanup call actually stops it)
async with _shared_server_lock:
if _shared_server is not None:
await _shared_server.stop()
_shared_server = None
self._server = None
self._initialized = False
@@ -100,7 +100,7 @@ class OpenAICompatibleLLM(LLMInterface):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Validate provider
valid_providers = ["openai", "groq", "ollama", "lmstudio", "minimax", "volcano"]
valid_providers = ["openai", "groq", "ollama", "lmstudio", "llamacpp", "minimax", "volcano", "openrouter"]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
@@ -114,13 +114,15 @@ class OpenAICompatibleLLM(LLMInterface):
self.base_url = "http://localhost:1234/v1"
elif self.provider == "minimax":
self.base_url = "https://api.minimax.io/v1"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if self.provider in ("openai", "groq", "minimax") and not self.api_key:
if self.provider in ("openai", "groq", "minimax", "openrouter") and not self.api_key:
raise ValueError(f"API key is required for {self.provider}")
# Service tier configuration (from config, not env vars)
@@ -199,8 +201,8 @@ class OpenAICompatibleLLM(LLMInterface):
require 'max_tokens'. Using a custom base_url with the openai provider
signals a third-party compatible API, so fall back to 'max_tokens'.
"""
# Native OpenAI (no custom base URL) and Groq use max_completion_tokens
if self.provider == "groq":
# Native OpenAI (no custom base URL), Groq, and llamacpp use max_completion_tokens
if self.provider in ("groq", "llamacpp"):
return "max_completion_tokens"
if self.provider == "openai" and not self.base_url:
return "max_completion_tokens"
@@ -335,8 +337,13 @@ class OpenAICompatibleLLM(LLMInterface):
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
if self.provider not in ("lmstudio", "ollama", "volcano"):
# LM Studio, Ollama and Volcano don't support json_object response format reliably
# Providers that skip json_object grammar enforcement
skip_grammar = self.provider in ("lmstudio", "ollama", "volcano")
if self.provider == "llamacpp":
from hindsight_api.config import get_config
skip_grammar = get_config().llamacpp_no_grammar
if not skip_grammar:
call_params["response_format"] = {"type": "json_object"}
last_exception = None
@@ -909,6 +909,7 @@ def _build_user_message(
event_date: datetime | None,
context: str,
metadata: dict[str, str] | None = None,
agent_name: str | None = None,
) -> str:
"""Build user message for fact extraction."""
from .orchestrator import parse_datetime_flexible
@@ -927,11 +928,15 @@ def _build_user_message(
metadata_lines = "\n".join(f" {k}: {v}" for k, v in metadata.items())
metadata_section = f"\nMetadata:\n{metadata_lines}"
narrator_section = ""
if agent_name:
narrator_section = f'\nNarrator: {agent_name} (AI agent — first-person statements like "I did X" are the agent\'s own actions; classify as "assistant")'
return f"""Extract facts from the following text chunk.
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_str}
Context: {sanitized_context}{metadata_section}
Context: {sanitized_context}{metadata_section}{narrator_section}
Text:
{sanitized_chunk}"""
@@ -995,7 +1000,7 @@ async def _extract_facts_from_chunk(
extract_causal_links = config.retain_extract_causal_links
# Build user message using helper function
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata)
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata, agent_name)
# Retry logic for JSON validation errors
# Use retain-specific overrides if set, otherwise fall back to global LLM config
@@ -1632,7 +1637,13 @@ async def extract_facts_from_contents_batch_api(
# Build user message using helper function
user_message = _build_user_message(
chunk, chunk_index_in_content, len(chunks), item.event_date, item.context, item.metadata or None
chunk,
chunk_index_in_content,
len(chunks),
item.event_date,
item.context,
item.metadata or None,
agent_name,
)
# Build request body using helper function
@@ -17,6 +17,23 @@ from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def get_document_content(
conn,
bank_id: str,
document_id: str,
) -> str | None:
"""Fetch the original_text of an existing document.
Returns None if the document does not exist.
"""
row = await conn.fetchval(
f"SELECT original_text FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
document_id,
bank_id,
)
return row
async def insert_facts_batch(
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
) -> list[str]:
@@ -523,6 +523,35 @@ async def retain_batch(
except Exception:
logger.warning("Failed to persist generated document_id", exc_info=True)
# --- Append mode: prepend existing document content to new content ---
# When update_mode="append", fetch the existing document text and prepend it
# so the full document is reprocessed (delta retain will skip unchanged chunks).
update_mode = None
for item in contents_dicts:
item_mode = item.get("update_mode")
if item_mode:
update_mode = item_mode
break
if update_mode == "append" and effective_doc_id and is_first_batch:
async with acquire_with_retry(pool) as conn:
existing_text = await fact_storage.get_document_content(conn, bank_id, effective_doc_id)
if existing_text:
# Prepend existing text as a new content item at the beginning
existing_content: RetainContentDict = {"content": existing_text}
# Copy context/tags from first item for consistency
first = contents_dicts[0]
if first.get("context"):
existing_content["context"] = first["context"]
if first.get("tags"):
existing_content["tags"] = first["tags"]
contents_dicts = [existing_content, *contents_dicts]
# Rebuild contents list to match
contents = _build_contents(contents_dicts, document_tags)
log_buffer.append(
f"[append] Prepended {len(existing_text):,} chars from existing document {effective_doc_id}"
)
# --- Delta retain: check if we can skip unchanged chunks ---
if is_first_batch:
delta_result = await _try_delta_retain(
@@ -1522,7 +1551,12 @@ def _map_results_to_contents(
"""Map created unit IDs back to original content items."""
facts_by_content: dict[int, list[int]] = {i: [] for i in range(len(contents))}
for i, fact in enumerate(extracted_facts):
facts_by_content[fact.content_index].append(i)
# Normalize content_index: some LLM providers return 1-indexed values.
# Clamp to valid range to prevent KeyError.
idx = fact.content_index
if idx < 0 or idx >= len(contents):
idx = min(max(idx, 0), len(contents) - 1) if len(contents) > 0 else 0
facts_by_content[idx].append(i)
result_unit_ids = []
unit_idx = 0
@@ -25,6 +25,9 @@ class RetainContentDict(TypedDict, total=False):
observation_scopes: How to scope observations for consolidation (optional).
"per_tag" runs one pass per individual tag; "combined" (default) runs a
single pass with all tags; a list[list[str]] specifies exact passes.
update_mode: How to handle existing documents with the same document_id (optional).
"replace" (default) deletes old data and reprocesses. "append" concatenates
new content to the existing document and reprocesses.
"""
content: str # Required
@@ -37,6 +40,7 @@ class RetainContentDict(TypedDict, total=False):
observation_scopes: (
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
) # Observation scopes for consolidation
update_mode: Literal["replace", "append"]
@dataclass
@@ -6,25 +6,30 @@ stored in memory_links:
1. Entity links — query-time self-join through unit_entities. Score = number of distinct
shared entities between the seed set and each candidate, computed via
COUNT(DISTINCT entity_id). More accurate than precomputed entity links.
COUNT(DISTINCT entity_id). Uses a LATERAL per-entity cap
(graph_per_entity_limit, default 200) to prevent high-fanout entities
from exploding the self-join intermediate rows.
2. Semantic links — precomputed kNN graph (each new fact linked to its top-5 most
similar existing facts at insert time, similarity >= 0.7). Checked
in both directions since the graph is not symmetric. Score = weight.
3. Causal links — explicit causal chains (causes/caused_by/enables/prevents).
Score = weight + 1.0 (boosted as highest-quality signal).
All three signals are bounded at retain time, so no LATERAL fan-out caps are needed
at query time. Each expansion is a simple aggregation over a small result set.
Entity expansion is bounded by graph_per_entity_limit (LATERAL cap per entity).
A timeout fallback (graph_expansion_timeout) drops entity expansion entirely if the
query still exceeds the budget.
For non-observation fact types the three expansions are issued as a single CTE query
(one roundtrip, one connection) with a `source` discriminator column so the Python
merge step can apply per-signal score transformations.
"""
import asyncio
import logging
import math
import time
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
@@ -262,35 +267,48 @@ class LinkExpansionRetriever(GraphRetriever):
idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
→ replaces costly BitmapAnd of two separate scans
"""
config = get_config()
ml = fq_table("memory_links")
mu = fq_table("memory_units")
ue = fq_table("unit_entities")
per_entity_limit = config.link_expansion_per_entity_limit
# Entity CTE with LATERAL fanout cap.
# Every seed entity (including high-frequency ones) is kept, but each
# entity's expansion is capped to per_entity_limit target units. The
# LATERAL subquery orders by unit_id DESC so the most recently inserted
# units are preferred (a recency proxy that is free — it rides the PK
# index with no extra sort).
entity_cte = f"""
seed_entities AS (
SELECT DISTINCT ue.entity_id
FROM {ue} ue
WHERE ue.unit_id = ANY($1::uuid[])
),
entity_expanded AS (
-- Entity co-occurrence via unit_entities self-join.
-- Finds units sharing entities with seeds at query time — more accurate
-- than precomputed entity links (no stale 50-neighbor cap).
-- Score = COUNT(DISTINCT shared entities), mapped to [0,1] via tanh.
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
COUNT(DISTINCT ue_seed.entity_id)::float AS score,
COUNT(DISTINCT se.entity_id)::float AS score,
'entity'::text AS source
FROM {ue} ue_seed
JOIN {ue} ue_target ON ue_seed.entity_id = ue_target.entity_id
JOIN {mu} mu ON mu.id = ue_target.unit_id
WHERE ue_seed.unit_id = ANY($1::uuid[])
AND ue_target.unit_id != ALL($1::uuid[])
AND mu.fact_type = $2
FROM seed_entities se
CROSS JOIN LATERAL (
SELECT ue_target.unit_id
FROM {ue} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
JOIN {mu} mu ON mu.id = t.unit_id
WHERE mu.fact_type = $2
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
)"""
all_rows = await conn.fetch(
f"""
WITH {entity_cte},
semantic_causal_cte = f"""
semantic_expanded AS (
-- Semantic kNN: both outgoing (seeds → their kNN at insert time) and
-- incoming (facts inserted after seeds that found seeds as kNN).
@@ -350,18 +368,37 @@ class LinkExpansionRetriever(GraphRetriever):
AND mu.fact_type = $2
ORDER BY mu.id, ml.weight DESC
LIMIT $3
)
)"""
full_query = f"""
WITH {entity_cte},
{semantic_causal_cte}
SELECT * FROM entity_expanded
UNION ALL
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
""",
seed_ids,
fact_type,
budget,
self.causal_weight_threshold,
)
"""
params = [seed_ids, fact_type, budget, self.causal_weight_threshold]
try:
all_rows = await asyncio.wait_for(
conn.fetch(full_query, *params),
timeout=config.link_expansion_timeout,
)
except asyncio.TimeoutError:
logger.warning(
f"[LinkExpansion] Entity expansion timed out after {config.link_expansion_timeout}s "
f"for fact_type={fact_type}, falling back to semantic+causal only"
)
fallback_query = f"""
WITH {semantic_causal_cte}
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
"""
all_rows = await conn.fetch(fallback_query, *params)
entity_rows = [r for r in all_rows if r["source"] == "entity"]
semantic_rows = [r for r in all_rows if r["source"] == "semantic"]
@@ -401,17 +438,31 @@ class LinkExpansionRetriever(GraphRetriever):
f"{len(source_ids_found)} source_memory_ids found"
)
config = get_config()
ue = fq_table("unit_entities")
per_entity_limit = config.link_expansion_per_entity_limit
connected_sources_cte = f"""
connected_sources AS (
-- Find sources sharing entities with seed observation sources
-- via unit_entities self-join (query-time, no precomputed links needed).
SELECT DISTINCT ue_target.unit_id AS source_id
source_entities AS (
SELECT DISTINCT ue_seed.entity_id
FROM seed_sources ss
JOIN {ue} ue_seed ON ue_seed.unit_id = ss.source_id
JOIN {ue} ue_target ON ue_seed.entity_id = ue_target.entity_id
WHERE ue_target.unit_id != ss.source_id
),
connected_sources AS (
-- Find sources sharing entities with seed observation sources
-- via LATERAL-capped self-join (prevents hub entity fanout).
SELECT DISTINCT t.unit_id AS source_id
FROM source_entities se
CROSS JOIN LATERAL (
SELECT ue_target.unit_id
FROM {ue} ue_target
WHERE ue_target.entity_id = se.entity_id
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
WHERE NOT EXISTS (
SELECT 1 FROM seed_sources ss WHERE ss.source_id = t.unit_id
)
)"""
entity_rows = await conn.fetch(
@@ -62,13 +62,14 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
if fact.context:
fact_obj["context"] = fact.context
# Add occurred_start if available (when the fact occurred)
if fact.occurred_start:
occurred_start = fact.occurred_start
if isinstance(occurred_start, str):
fact_obj["occurred_start"] = occurred_start
elif isinstance(occurred_start, datetime):
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
# Add temporal fields if available
for field_name in ("occurred_start", "occurred_end", "mentioned_at"):
value = getattr(fact, field_name, None)
if value:
if isinstance(value, str):
fact_obj[field_name] = value
elif isinstance(value, datetime):
fact_obj[field_name] = value.strftime("%Y-%m-%d %H:%M:%S")
formatted.append(fact_obj)
+137 -2
View File
@@ -29,6 +29,7 @@ from hindsight_api.models import RequestContext
_ALL_TOOLS: frozenset[str] = frozenset(
{
"retain",
"sync_retain",
"recall",
"reflect",
"list_banks",
@@ -139,6 +140,7 @@ def build_content_dict(
metadata: dict[str, str] | None = None,
document_id: str | None = None,
strategy: str | None = None,
update_mode: str | None = None,
) -> tuple[dict[str, Any], str | None]:
"""Build a content dict for retain operations.
@@ -150,6 +152,7 @@ def build_content_dict(
metadata: Optional key-value metadata to attach to the memory
document_id: Optional document ID to associate the memory with
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
update_mode: How to handle existing documents ('replace' or 'append')
Returns:
Tuple of (content_dict, error_message). error_message is None if successful.
@@ -184,6 +187,8 @@ def build_content_dict(
content_dict["document_id"] = document_id
if strategy is not None:
content_dict["strategy"] = strategy
if update_mode is not None:
content_dict["update_mode"] = update_mode
return content_dict, None
@@ -202,6 +207,7 @@ def register_mcp_tools(
"""
tools_to_register = config.tools or {
"retain",
"sync_retain",
"recall",
"reflect",
"list_banks",
@@ -235,6 +241,9 @@ def register_mcp_tools(
if "retain" in tools_to_register:
_register_retain(mcp, memory, config)
if "sync_retain" in tools_to_register:
_register_sync_retain(mcp, memory, config)
if "recall" in tools_to_register:
_register_recall(mcp, memory, config)
@@ -539,6 +548,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
document_id: str | None = None,
bank_id: str | None = None,
strategy: str | None = None,
update_mode: str | None = None,
) -> dict:
"""
Args:
@@ -550,12 +560,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
document_id: Optional document ID to associate this memory with
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing).
"""
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
content_dict, error = build_content_dict(
content, context, timestamp, tags, metadata, document_id, strategy, update_mode
)
if error:
return {"status": "error", "message": error}
@@ -590,6 +603,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
metadata: dict[str, str] | None = None,
document_id: str | None = None,
strategy: str | None = None,
update_mode: str | None = None,
) -> dict:
"""
Args:
@@ -600,12 +614,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
document_id: Optional document ID to associate this memory with
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing).
"""
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
content_dict, error = build_content_dict(
content, context, timestamp, tags, metadata, document_id, strategy, update_mode
)
if error:
return {"status": "error", "message": error}
@@ -630,6 +647,124 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
return {"status": "error", "message": str(e)}
def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the sync_retain tool (synchronous retain that waits for completion)."""
if config.include_bank_id_param:
@mcp.tool()
async def sync_retain(
content: str,
context: str = "general",
timestamp: str | None = None,
tags: list[str] | None = None,
metadata: dict[str, str] | None = None,
document_id: str | None = None,
bank_id: str | None = None,
strategy: str | None = None,
) -> dict:
"""Store information to long-term memory and wait for completion.
Unlike retain (which is asynchronous), this tool blocks until the memory
is fully stored and immediately available for recall.
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
document_id: Optional document ID to associate this memory with
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
"""
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
if error:
return {"status": "error", "message": error}
request_context = _get_request_context(config)
try:
result = await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=request_context,
strategy=content_dict.pop("strategy", None),
)
memory_ids = [uid for batch in result for uid in batch]
return {
"status": "completed",
"message": "Memory stored successfully",
"memory_ids": memory_ids,
}
except OperationValidationError as e:
logger.warning(f"Sync retain rejected: {e}")
return {"status": "error", "message": str(e)}
except Exception as e:
logger.error(f"Error in sync retain: {e}", exc_info=True)
return {"status": "error", "message": str(e)}
else:
@mcp.tool()
async def sync_retain(
content: str,
context: str = "general",
timestamp: str | None = None,
tags: list[str] | None = None,
metadata: dict[str, str] | None = None,
document_id: str | None = None,
strategy: str | None = None,
) -> dict:
"""Store information to long-term memory and wait for completion.
Unlike retain (which is asynchronous), this tool blocks until the memory
is fully stored and immediately available for recall.
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
document_id: Optional document ID to associate this memory with
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
"""
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"status": "error", "message": "No bank_id configured"}
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
if error:
return {"status": "error", "message": error}
request_context = _get_request_context(config)
try:
result = await memory.retain_batch_async(
bank_id=target_bank,
contents=[content_dict],
request_context=request_context,
strategy=content_dict.pop("strategy", None),
)
memory_ids = [uid for batch in result for uid in batch]
return {
"status": "completed",
"message": "Memory stored successfully",
"memory_ids": memory_ids,
}
except OperationValidationError as e:
logger.warning(f"Sync retain rejected: {e}")
return {"status": "error", "message": str(e)}
except Exception as e:
logger.error(f"Error in sync retain: {e}", exc_info=True)
return {"status": "error", "message": str(e)}
def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the recall tool."""
description = config.recall_description or DEFAULT_MCP_RECALL_DESCRIPTION
+8 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.4.22"
version = "0.5.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -21,7 +21,7 @@ dependencies = [
"sqlalchemy>=2.0.44",
"alembic>=1.17.1",
"pgvector>=0.4.1",
"greenlet>=3.2.4",
"greenlet>=3.2.4,<3.4.0", # 3.4.0 lacks arm64 wheels for manylinux_2_41
"psycopg2-binary>=2.9.11",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
@@ -40,7 +40,7 @@ dependencies = [
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"litellm>=1.0.0,<=1.82.6", # 1.82.7+ contains a supply chain attack (malicious .pth credential stealer)
"litellm>=1.83.0", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
"winloop>=0.1.0; sys_platform == 'win32'",
@@ -78,6 +78,11 @@ local-ml = [
"mlx-lm>=0.31.1",
"safetensors>=0.6.2",
]
local-llm = [
# Built-in llama.cpp inference for fully offline operation
"llama-cpp-python[server]>=0.3.0",
"huggingface-hub>=0.20.0",
]
embedded-db = [
"pg0-embedded>=0.11.0",
]
@@ -7,7 +7,6 @@ relevance score, independent of the cross-encoder model's score calibration.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
@@ -23,13 +22,18 @@ def _make_result(
occurred_start: datetime | None = None,
temporal_proximity: float | None = None,
) -> ScoredResult:
retrieval = MagicMock(spec=RetrievalResult)
retrieval.occurred_start = occurred_start
retrieval.temporal_proximity = temporal_proximity
retrieval = RetrievalResult(
id="test",
text="test",
fact_type="world",
occurred_start=occurred_start,
temporal_proximity=temporal_proximity,
)
candidate = MagicMock(spec=MergedCandidate)
candidate.retrieval = retrieval
candidate.rrf_score = 0.05
candidate = MergedCandidate(
retrieval=retrieval,
rrf_score=0.05,
)
return ScoredResult(
candidate=candidate,
@@ -0,0 +1,65 @@
"""
Tests for format_facts_for_prompt in think_utils.
"""
import json
from hindsight_api.engine.response_models import MemoryFact
from hindsight_api.engine.search.think_utils import format_facts_for_prompt
def test_format_facts_includes_temporal_fields():
"""All temporal fields (occurred_start, occurred_end, mentioned_at) should appear in the JSON."""
facts = [
MemoryFact(
id="fact-1",
text="Team offsite in February",
fact_type="experience",
occurred_start="2024-02-01T00:00:00Z",
occurred_end="2024-02-28T23:59:59Z",
mentioned_at="2024-03-05T10:00:00Z",
)
]
result = json.loads(format_facts_for_prompt(facts))
assert len(result) == 1
assert result[0]["text"] == "Team offsite in February"
assert result[0]["occurred_start"] == "2024-02-01T00:00:00Z"
assert result[0]["occurred_end"] == "2024-02-28T23:59:59Z"
assert result[0]["mentioned_at"] == "2024-03-05T10:00:00Z"
def test_format_facts_omits_null_temporal_fields():
"""Null temporal fields should not appear in the JSON."""
facts = [
MemoryFact(
id="fact-2",
text="The sky is blue",
fact_type="world",
)
]
result = json.loads(format_facts_for_prompt(facts))
assert len(result) == 1
assert "occurred_start" not in result[0]
assert "occurred_end" not in result[0]
assert "mentioned_at" not in result[0]
def test_format_facts_partial_temporal_fields():
"""Only non-null temporal fields should appear."""
facts = [
MemoryFact(
id="fact-3",
text="Meeting happened",
fact_type="experience",
occurred_start="2024-06-01T09:00:00Z",
)
]
result = json.loads(format_facts_for_prompt(facts))
assert result[0]["occurred_start"] == "2024-06-01T09:00:00Z"
assert "occurred_end" not in result[0]
assert "mentioned_at" not in result[0]
def test_format_facts_empty_list():
"""Empty list should return '[]'."""
assert format_facts_for_prompt([]) == "[]"
@@ -0,0 +1,211 @@
"""
Tests for LATERAL entity fanout cap in graph expansion.
Verifies that the per-entity LIMIT in _expand_combined prevents high-fanout
entities from exploding the self-join, while still returning entity-based
graph results.
"""
import asyncio
from datetime import datetime, timezone
import pytest
@pytest.mark.asyncio
async def test_high_fanout_entity_returns_results(memory, request_context):
"""
A high-fanout entity (appearing in many facts) should still produce
graph retrieval results the LATERAL cap limits rows per entity but
does not drop the entity entirely.
"""
bank_id = f"test_fanout_cap_{datetime.now(timezone.utc).timestamp()}"
try:
# Create many facts sharing one common entity ("Acme Corp") plus
# a few with a unique entity so we can query for the unique one
# and verify graph expansion finds siblings via "Acme Corp".
contents = [
# Target: unique entity "Zara" shares "Acme Corp" with the rest
{
"content": "Zara joined Acme Corp as a senior engineer last month",
"context": "hr update",
"entities": [{"text": "Zara"}, {"text": "Acme Corp"}],
},
]
# Add many facts that all share "Acme Corp" — creates a high-fanout entity
for i in range(60):
contents.append(
{
"content": f"Employee {i} completed onboarding at Acme Corp in department {i % 5}",
"context": "hr update",
"entities": [{"text": f"Employee {i}"}, {"text": "Acme Corp"}],
}
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
from hindsight_api.engine.memory_engine import Budget
# Query for "Zara" — semantic search finds Zara's fact as a seed,
# then graph expansion should find other Acme Corp facts via the
# shared entity, even though "Acme Corp" has 60+ mentions.
result = await memory.recall_async(
bank_id=bank_id,
query="Zara",
budget=Budget.HIGH,
max_tokens=4096,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
assert result.results is not None
assert len(result.results) > 0
# Verify graph retrieval ran and found results
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
assert len(graph_results) > 0, "Graph retrieval should have run"
# At least one graph result should contain Acme Corp content
# (found via shared entity, not just semantic similarity)
all_texts = [r.text for r in result.results]
acme_found = any("Acme Corp" in t for t in all_texts)
assert acme_found, "Should find Acme Corp facts via entity graph expansion"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_entity_expansion_timeout_fallback(memory, request_context):
"""
When graph_expansion_timeout is set very low, entity expansion should
time out gracefully and fall back to semantic+causal links only,
rather than failing the entire recall.
"""
bank_id = f"test_timeout_fallback_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Alice works on the backend API at TechCorp",
"context": "team info",
"entities": [{"text": "Alice"}, {"text": "TechCorp"}],
},
{
"content": "Bob maintains the frontend at TechCorp",
"context": "team info",
"entities": [{"text": "Bob"}, {"text": "TechCorp"}],
},
],
request_context=request_context,
)
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
config = _get_raw_config()
original_timeout = config.link_expansion_timeout
try:
# Set an impossibly low timeout to force the fallback path
config.link_expansion_timeout = 0.0001
result = await memory.recall_async(
bank_id=bank_id,
query="Alice",
budget=Budget.MID,
max_tokens=2048,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
# Recall should succeed even when entity expansion times out
assert result.results is not None
assert len(result.results) > 0
# Alice should still be found via semantic search
result_texts = [r.text for r in result.results]
alice_found = any("Alice" in t for t in result_texts)
assert alice_found, "Should find Alice via semantic search despite graph timeout"
finally:
config.link_expansion_timeout = original_timeout
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_per_entity_limit_caps_expansion(memory, request_context):
"""
With graph_per_entity_limit set to a small value, entity expansion should
still work but return fewer results from high-fanout entities.
"""
bank_id = f"test_per_entity_limit_{datetime.now(timezone.utc).timestamp()}"
try:
# Create facts with a shared entity
contents = [
{
"content": "Lead engineer Dana oversees the Widgets project at MegaCorp",
"context": "project info",
"entities": [{"text": "Dana"}, {"text": "MegaCorp"}],
},
]
for i in range(30):
contents.append(
{
"content": f"MegaCorp hired contractor {i} for the Q4 push",
"context": "hiring info",
"entities": [{"text": f"Contractor {i}"}, {"text": "MegaCorp"}],
}
)
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
config = _get_raw_config()
original_limit = config.link_expansion_per_entity_limit
try:
# Set a very small per-entity limit
config.link_expansion_per_entity_limit = 5
result = await memory.recall_async(
bank_id=bank_id,
query="Dana",
budget=Budget.HIGH,
max_tokens=4096,
enable_trace=True,
request_context=request_context,
_quiet=True,
)
# Recall should succeed with the cap
assert result.results is not None
assert len(result.results) > 0
# Graph retrieval should have run
retrieval_results = result.trace.get("retrieval_results", [])
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
assert len(graph_results) > 0, "Graph retrieval should have run"
finally:
config.link_expansion_per_entity_limit = original_limit
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -345,6 +345,65 @@ class TestLiteLLMSDKEmbeddings:
assert encode_call_args.kwargs["api_base"] == "https://custom.api.com"
assert encode_call_args.kwargs["dimensions"] == 768
async def test_encoding_format_default_is_float(self, mock_litellm):
"""Test that encoding_format defaults to 'float' for backwards compatibility."""
with patch(
"builtins.__import__",
side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args),
):
emb = LiteLLMSDKEmbeddings(
api_key="test_key",
model="cohere/embed-english-v3.0",
)
await emb.initialize()
init_call_args = mock_litellm.aembedding.call_args
assert init_call_args.kwargs["encoding_format"] == "float"
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
emb.encode(["test"])
encode_call_args = mock_litellm.embedding.call_args
assert encode_call_args.kwargs["encoding_format"] == "float"
async def test_encoding_format_omitted_when_none(self, mock_litellm):
"""Test that encoding_format is omitted when set to None (for Voyage AI, Gemini)."""
with patch(
"builtins.__import__",
side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args),
):
emb = LiteLLMSDKEmbeddings(
api_key="test_key",
model="voyage/voyage-4-large",
encoding_format=None,
)
await emb.initialize()
init_call_args = mock_litellm.aembedding.call_args
assert "encoding_format" not in init_call_args.kwargs
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
emb.encode(["test"])
encode_call_args = mock_litellm.embedding.call_args
assert "encoding_format" not in encode_call_args.kwargs
async def test_encoding_format_omitted_when_empty_string(self, mock_litellm):
"""Test that encoding_format is omitted when set to empty string."""
with patch(
"builtins.__import__",
side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args),
):
emb = LiteLLMSDKEmbeddings(
api_key="test_key",
model="gemini/gemini-embedding-2-preview",
encoding_format="",
)
await emb.initialize()
init_call_args = mock_litellm.aembedding.call_args
assert "encoding_format" not in init_call_args.kwargs
async def test_openai_invalid_output_dimensions_raises(self, mock_litellm):
"""Invalid dimensions fail during initialize() (probe call), not per HTTP request.
+57 -1
View File
@@ -342,7 +342,8 @@ class TestMentalModelToolRegistration:
assert "update_bank" in tools
assert "delete_bank" in tools
assert "clear_memories" in tools
assert len(tools) == 29
assert "sync_retain" in tools
assert len(tools) == 30
@pytest.fixture
@@ -1107,12 +1108,67 @@ class TestMemoryBrowsingTools:
assert '"deleted"' in result
assert mock_memory.delete_memory_unit.call_args.kwargs["unit_id"] == "mem-1"
async def test_get_memory_invalid_uuid(self, mock_memory):
mock_memory.get_memory_unit.side_effect = ValueError("Invalid memory_id: 'nonexistent' is not a valid UUID")
mcp = _make_mcp_server(mock_memory, {"get_memory"}, include_bank_id=True)
result = await _tools(mcp)["get_memory"].fn(memory_id="nonexistent")
assert "not a valid UUID" in result
async def test_get_memory_invalid_uuid_single_bank(self, mock_memory):
mock_memory.get_memory_unit.side_effect = ValueError("Invalid memory_id: 'bad' is not a valid UUID")
mcp = _make_mcp_server(mock_memory, {"get_memory"}, include_bank_id=False)
result = await _tools(mcp)["get_memory"].fn(memory_id="bad")
assert "not a valid UUID" in result["error"]
async def test_delete_memory_invalid_uuid(self, mock_memory):
mock_memory.delete_memory_unit.side_effect = ValueError("Invalid unit_id: 'bad' is not a valid UUID")
mcp = _make_mcp_server(mock_memory, {"delete_memory"}, include_bank_id=True)
result = await _tools(mcp)["delete_memory"].fn(memory_id="bad")
assert "not a valid UUID" in result
async def test_list_memories_single_bank(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=False)
result = await _tools(mcp)["list_memories"].fn()
assert isinstance(result, dict)
# =========================================================================
# Sync Retain Tool Tests
# =========================================================================
@pytest.mark.asyncio
class TestSyncRetainTool:
async def test_sync_retain_basic(self, mock_memory):
mock_memory.retain_batch_async.return_value = [["unit-1", "unit-2"]]
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=True)
result = await _tools(mcp)["sync_retain"].fn(content="test memory")
assert result["status"] == "completed"
assert result["memory_ids"] == ["unit-1", "unit-2"]
async def test_sync_retain_single_bank(self, mock_memory):
mock_memory.retain_batch_async.return_value = [["unit-1"]]
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=False)
result = await _tools(mcp)["sync_retain"].fn(content="test memory")
assert result["status"] == "completed"
assert result["memory_ids"] == ["unit-1"]
async def test_sync_retain_with_tags(self, mock_memory):
mock_memory.retain_batch_async.return_value = [["unit-1"]]
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=True)
result = await _tools(mcp)["sync_retain"].fn(content="test", tags=["project:alpha"])
assert result["status"] == "completed"
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["contents"][0]["tags"] == ["project:alpha"]
async def test_sync_retain_error(self, mock_memory):
mock_memory.retain_batch_async.side_effect = Exception("DB error")
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=True)
result = await _tools(mcp)["sync_retain"].fn(content="test")
assert result["status"] == "error"
assert "DB error" in result["message"]
# =========================================================================
# Document Tool Tests
# =========================================================================
@@ -14,24 +14,19 @@ UTC = timezone.utc
def create_mock_scored_result(proof_count: int | None = None, ce_score: float = 0.8) -> ScoredResult:
"""Helper to create a minimal ScoredResult suitable for scoring tests."""
retrieval = RetrievalResult(
id=uuid4(),
id=str(uuid4()),
text="Test mock fact",
fact_type="observation" if proof_count is not None else "world",
document_id=uuid4(),
chunk_id=uuid4(),
embedding=[0.1]*384,
similarity=0.9,
document_id=str(uuid4()),
chunk_id=str(uuid4()),
proof_count=proof_count,
# Default neutral dates for testing so only proof_count changes score
occurred_start=datetime.now(UTC),
occurred_end=datetime.now(UTC)
# Use None for neutral recency so only proof_count changes score
occurred_start=None,
occurred_end=None
)
candidate = MergedCandidate(
id=retrieval.id,
retrieval=retrieval,
semantic_rank=1,
bm25_rank=1,
rrf_score=0.1
rrf_score=0.1,
)
return ScoredResult(
candidate=candidate,
@@ -0,0 +1,240 @@
"""
Tests for retain update_mode='append' appends new content to existing documents.
"""
import logging
from datetime import datetime, timezone
import pytest
from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
def _ts():
return datetime.now(timezone.utc).timestamp()
@pytest.mark.asyncio
async def test_append_mode_concatenates_content(memory, request_context):
"""
When update_mode='append', new content should be appended to the existing
document and the full document should be reprocessed. Facts from both
old and new content should be recallable.
"""
bank_id = f"test_append_{_ts()}"
document_id = "conversation-append"
try:
# First retain — initial content
v1_units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a software engineer.",
context="team info",
document_id=document_id,
request_context=request_context,
)
assert len(v1_units) > 0, "v1 should create facts"
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
v1_text = doc_v1["original_text"]
assert "Alice works at Google" in v1_text
# Second retain with append — add new content
v2_units = await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Bob works at Microsoft as a data scientist.",
"context": "team info",
"document_id": document_id,
"update_mode": "append",
}
],
request_context=request_context,
)
# Verify document now contains both old and new content
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
v2_text = doc_v2["original_text"]
assert "Alice works at Google" in v2_text, "Original content should be preserved"
assert "Bob works at Microsoft" in v2_text, "New content should be appended"
# Verify facts from both old and new content are recallable
result_alice = await memory.recall_async(
bank_id=bank_id,
query="Where does Alice work?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result_alice.results) > 0, "Should recall facts about Alice"
result_bob = await memory.recall_async(
bank_id=bank_id,
query="Where does Bob work?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result_bob.results) > 0, "Should recall facts about Bob"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_append_mode_no_existing_document(memory, request_context):
"""
When update_mode='append' but no existing document exists,
it should behave like a normal retain (no content to prepend).
"""
bank_id = f"test_append_new_{_ts()}"
document_id = "new-doc-append"
try:
units = await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Charlie is a product manager at Stripe.",
"context": "team info",
"document_id": document_id,
"update_mode": "append",
}
],
request_context=request_context,
)
assert len(units) > 0, "Should create facts even with no existing document"
# Flatten if nested
flat_units = units[0] if units and isinstance(units[0], list) else units
assert len(flat_units) > 0
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
assert "Charlie is a product manager" in doc["original_text"]
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_append_mode_requires_document_id(memory, request_context):
"""update_mode='append' without document_id should raise ValueError."""
bank_id = f"test_append_no_docid_{_ts()}"
with pytest.raises(ValueError, match="update_mode='append' requires a document_id"):
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Some content",
"update_mode": "append",
}
],
request_context=request_context,
)
@pytest.mark.asyncio
async def test_append_mode_multiple_appends(memory, request_context):
"""Multiple appends should accumulate content over successive retains."""
bank_id = f"test_multi_append_{_ts()}"
document_id = "multi-append-doc"
try:
# Initial retain
await memory.retain_async(
bank_id=bank_id,
content="Day 1: Alice joined the team.",
context="journal",
document_id=document_id,
request_context=request_context,
)
# First append
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Day 2: Alice completed her onboarding.",
"context": "journal",
"document_id": document_id,
"update_mode": "append",
}
],
request_context=request_context,
)
# Second append
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Day 3: Alice shipped her first feature.",
"context": "journal",
"document_id": document_id,
"update_mode": "append",
}
],
request_context=request_context,
)
# Verify all content is present
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
text = doc["original_text"]
assert "Day 1" in text, "Original content should be present"
assert "Day 2" in text, "First append should be present"
assert "Day 3" in text, "Second append should be present"
# All days should be recallable
result = await memory.recall_async(
bank_id=bank_id,
query="What happened on Alice's first days?",
budget=Budget.MID,
max_tokens=1000,
request_context=request_context,
)
assert len(result.results) > 0, "Should recall facts from all appends"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_replace_mode_is_default(memory, request_context):
"""Without update_mode (or update_mode='replace'), retain should replace content."""
bank_id = f"test_replace_default_{_ts()}"
document_id = "replace-doc"
try:
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="team info",
document_id=document_id,
request_context=request_context,
)
# Retain again without update_mode — should replace
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{
"content": "Bob works at Microsoft.",
"context": "team info",
"document_id": document_id,
}
],
request_context=request_context,
)
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
text = doc["original_text"]
# With replace, only new content should remain
assert "Bob works at Microsoft" in text, "New content should be present"
assert "Alice works at Google" not in text, "Old content should be replaced"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.4.22"
version = "0.5.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+90
View File
@@ -0,0 +1,90 @@
# Hindsight CLI ↔ OpenAPI coverage manifest.
#
# The CI job `cli-coverage-check` (see hindsight-dev/hindsight_dev/cli_coverage_check.py)
# enforces both endpoint-level and parameter-level coverage:
#
# 1. Every operationId in hindsight-docs/static/openapi.json must be either
# called from hindsight-cli/src/**/*.rs (the progenitor-generated client
# methods are named identically to the operationId) or listed under
# [skip] below with a reason.
#
# 2. For each operation with a JSON request body, every top-level property
# of that body must be either present in hindsight-cli/src/main.rs as a
# clap command variant field (`field_name: <type>`) or a `long = "..."`
# attribute, OR listed under [fields.<operation_id>] below with a reason.
#
# Skip entries should explain *why* the field/operation is not exposed (e.g.
# flattened into several CLI flags, complex nested struct, available via a
# different subcommand).
# ---------------------------------------------------------------------------
# Operation-level skips
# ---------------------------------------------------------------------------
[skip]
# (empty — every operation is currently wired)
# ---------------------------------------------------------------------------
# Per-operation parameter skips
# ---------------------------------------------------------------------------
[fields.add_bank_background]
update_disposition = "Exposed inverted as --no-update-disposition on `bank background`."
[fields.create_or_update_bank]
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank create`."
disposition_skepticism = "Covered by --skepticism; the flat form is an API alias."
disposition_literalism = "Covered by --literalism; the flat form is an API alias."
disposition_empathy = "Covered by --empathy; the flat form is an API alias."
background = "Set via the dedicated `bank background` subcommand."
reflect_mission = "Set via `bank set-config --reflect-mission`."
retain_mission = "Set via `bank set-config --retain-mission`."
retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`."
retain_custom_instructions = "Set via `bank set-config` (hierarchical config)."
retain_chunk_size = "Set via `bank set-config` (hierarchical config)."
enable_observations = "Set via `bank set-config` (hierarchical config)."
observations_mission = "Set via `bank set-config --observations-mission`."
[fields.update_bank]
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank update`."
disposition_skepticism = "Covered by --skepticism; the flat form is an API alias."
disposition_literalism = "Covered by --literalism; the flat form is an API alias."
disposition_empathy = "Covered by --empathy; the flat form is an API alias."
background = "Set via the dedicated `bank background` subcommand."
reflect_mission = "Set via `bank set-config --reflect-mission`."
retain_mission = "Set via `bank set-config --retain-mission`."
retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`."
retain_custom_instructions = "Set via `bank set-config` (hierarchical config)."
retain_chunk_size = "Set via `bank set-config` (hierarchical config)."
enable_observations = "Set via `bank set-config` (hierarchical config)."
observations_mission = "Set via `bank set-config --observations-mission`."
[fields.update_bank_disposition]
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank set-disposition`."
[fields.update_bank_config]
updates = "Flattened into per-setting flags (--llm-provider, --llm-model, etc) on `bank set-config`."
[fields.create_webhook]
http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed."
[fields.update_webhook]
http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed."
[fields.recall_memories]
types = "CLI exposes this as --fact-type (the schema property is named `types` but it holds fact types)."
include = "Flattened into --include-chunks / --chunk-max-tokens (facts are always included)."
tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases."
[fields.reflect]
include = "Flattened into --include-facts and related flags."
response_schema = "Exposed as --schema (path to a JSON schema file)."
tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases."
[fields.retain_memories]
items = "Constructed from the single positional content argument on `memory retain`."
[fields.create_mental_model]
trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model create` (other nested trigger fields like fact_types/tag_groups are not exposed yet)."
[fields.update_mental_model]
trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model update` (other nested trigger fields like fact_types/tag_groups are not exposed yet)."
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.22"
version = "0.5.0"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+35
View File
@@ -118,6 +118,41 @@ run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
# Test 15: List operations
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
# --- Coverage-critical commands (added to ensure CLI exercises every endpoint) ---
# Test: Set disposition directly (PUT /profile)
run_test "bank set-disposition" "$HINDSIGHT_CLI" bank set-disposition "$TEST_BANK" \
--skepticism 3 --literalism 3 --empathy 3 || FAILED=1
# Test: Recover consolidation (no-op when nothing stalled, but exercises the endpoint)
run_test "bank consolidation-recover" "$HINDSIGHT_CLI" bank consolidation-recover "$TEST_BANK" || FAILED=1
# Test: Bank template schema
run_test "bank template-schema" "$HINDSIGHT_CLI" bank template-schema -o json || FAILED=1
# Test: Export bank template
run_test "bank export-template" "$HINDSIGHT_CLI" bank export-template "$TEST_BANK" -o json || FAILED=1
# Test: Audit log list + stats
run_test "audit list" "$HINDSIGHT_CLI" audit list "$TEST_BANK" -o json || FAILED=1
run_test "audit stats" "$HINDSIGHT_CLI" audit stats "$TEST_BANK" -o json || FAILED=1
# Test: Webhook lifecycle (list / create / update / deliveries / delete)
run_test "webhook list (empty)" "$HINDSIGHT_CLI" webhook list "$TEST_BANK" -o json || FAILED=1
WEBHOOK_OUT=$("$HINDSIGHT_CLI" webhook create "$TEST_BANK" https://example.invalid/hook -o json 2>/tmp/cli-test-output.txt || true)
if echo "$WEBHOOK_OUT" | grep -q '"id"'; then
echo "Testing: webhook create... OK"
WEBHOOK_ID=$(echo "$WEBHOOK_OUT" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
run_test "webhook update" "$HINDSIGHT_CLI" webhook update "$TEST_BANK" "$WEBHOOK_ID" --enabled false || FAILED=1
run_test "webhook deliveries" "$HINDSIGHT_CLI" webhook deliveries "$TEST_BANK" "$WEBHOOK_ID" -o json || FAILED=1
run_test "webhook delete" "$HINDSIGHT_CLI" webhook delete "$TEST_BANK" "$WEBHOOK_ID" -y || FAILED=1
else
echo "Testing: webhook create... FAILED"
cat /tmp/cli-test-output.txt | sed 's/^/ /'
FAILED=1
fi
# Test 16: Delete bank
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
+608 -84
View File
@@ -4,8 +4,8 @@
//! to bridge from the CLI's synchronous code to the async API client.
use anyhow::Result;
use hindsight_client::Client as AsyncClient;
pub use hindsight_client::types;
use hindsight_client::Client as AsyncClient;
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
@@ -76,8 +76,8 @@ impl ApiClient {
let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?);
// Create HTTP client with 2-minute timeout and optional auth header
let mut client_builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120));
let mut client_builder =
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
if let Some(key) = api_key {
let mut headers = reqwest::header::HeaderMap::new();
@@ -92,7 +92,12 @@ impl ApiClient {
let http_client = client_builder.build()?;
let client = AsyncClient::new_with_client(&base_url, http_client.clone());
Ok(ApiClient { client, http_client, base_url, runtime })
Ok(ApiClient {
client,
http_client,
base_url,
runtime,
})
}
pub fn list_agents(&self, _verbose: bool) -> Result<Vec<types::BankListItem>> {
@@ -102,7 +107,11 @@ impl ApiClient {
})
}
pub fn get_profile(&self, agent_id: &str, _verbose: bool) -> Result<types::BankProfileResponse> {
pub fn get_profile(
&self,
agent_id: &str,
_verbose: bool,
) -> Result<types::BankProfileResponse> {
self.runtime.block_on(async {
let response = self.client.get_bank_profile(agent_id, None).await?;
Ok(response.into_inner())
@@ -120,7 +129,12 @@ impl ApiClient {
})
}
pub fn update_agent_name(&self, agent_id: &str, name: &str, _verbose: bool) -> Result<types::BankProfileResponse> {
pub fn update_agent_name(
&self,
agent_id: &str,
name: &str,
_verbose: bool,
) -> Result<types::BankProfileResponse> {
self.runtime.block_on(async {
let request = types::CreateBankRequest {
name: Some(name.to_string()),
@@ -129,25 +143,45 @@ impl ApiClient {
disposition: None,
..Default::default()
};
let response = self.client.create_or_update_bank(agent_id, None, &request).await?;
let response = self
.client
.create_or_update_bank(agent_id, None, &request)
.await?;
Ok(response.into_inner())
})
}
pub fn add_background(&self, agent_id: &str, content: &str, update_disposition: bool, _verbose: bool) -> Result<types::BackgroundResponse> {
pub fn add_background(
&self,
agent_id: &str,
content: &str,
update_disposition: bool,
_verbose: bool,
) -> Result<types::BackgroundResponse> {
self.runtime.block_on(async {
let request = types::AddBackgroundRequest {
content: content.to_string(),
update_disposition,
};
let response = self.client.add_bank_background(agent_id, None, &request).await?;
let response = self
.client
.add_bank_background(agent_id, None, &request)
.await?;
Ok(response.into_inner())
})
}
pub fn recall(&self, agent_id: &str, request: &types::RecallRequest, verbose: bool) -> Result<types::RecallResponse> {
pub fn recall(
&self,
agent_id: &str,
request: &types::RecallRequest,
verbose: bool,
) -> Result<types::RecallResponse> {
if verbose {
eprintln!("Request body: {}", serde_json::to_string_pretty(request).unwrap_or_default());
eprintln!(
"Request body: {}",
serde_json::to_string_pretty(request).unwrap_or_default()
);
}
self.runtime.block_on(async {
let response = self.client.recall_memories(agent_id, None, request).await?;
@@ -155,14 +189,25 @@ impl ApiClient {
})
}
pub fn reflect(&self, agent_id: &str, request: &types::ReflectRequest, _verbose: bool) -> Result<types::ReflectResponse> {
pub fn reflect(
&self,
agent_id: &str,
request: &types::ReflectRequest,
_verbose: bool,
) -> Result<types::ReflectResponse> {
self.runtime.block_on(async {
let response = self.client.reflect(agent_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn retain(&self, agent_id: &str, request: &types::RetainRequest, _async_mode: bool, _verbose: bool) -> Result<MemoryPutResult> {
pub fn retain(
&self,
agent_id: &str,
request: &types::RetainRequest,
_async_mode: bool,
_verbose: bool,
) -> Result<MemoryPutResult> {
self.runtime.block_on(async {
let response = self.client.retain_memories(agent_id, None, request).await?;
let result = response.into_inner();
@@ -186,7 +231,10 @@ impl ApiClient {
verbose: bool,
) -> Result<FileRetainResult> {
self.runtime.block_on(async {
let url = format!("{}/v1/default/banks/{}/files/retain", self.base_url, bank_id);
let url = format!(
"{}/v1/default/banks/{}/files/retain",
self.base_url, bank_id
);
let files_metadata: Vec<serde_json::Value> = files
.iter()
@@ -210,8 +258,8 @@ impl ApiClient {
"files_metadata": files_metadata,
});
let mut form = reqwest::multipart::Form::new()
.text("request", request_json.to_string());
let mut form =
reqwest::multipart::Form::new().text("request", request_json.to_string());
for (filename, content) in files {
let part = reqwest::multipart::Part::bytes(content)
@@ -239,10 +287,18 @@ impl ApiClient {
/// Poll an operation until it completes or fails.
/// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error.
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
pub fn poll_operation(
&self,
agent_id: &str,
operation_id: &str,
verbose: bool,
) -> Result<(bool, Option<String>)> {
self.runtime.block_on(async {
loop {
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
let response = self
.client
.list_operations(agent_id, None, None, None, None, None)
.await?;
let ops = response.into_inner();
// Find our operation
@@ -267,7 +323,10 @@ impl ApiClient {
}
_ => {
// Unknown status, treat as failed
return Ok((false, Some(format!("Unknown status: {}", operation.status))));
return Ok((
false,
Some(format!("Unknown status: {}", operation.status)),
));
}
}
}
@@ -280,43 +339,82 @@ impl ApiClient {
})
}
pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
pub fn delete_memory(
&self,
_agent_id: &str,
_unit_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
// Note: Individual memory deletion is no longer supported in the API
anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.")
}
pub fn clear_memories(&self, agent_id: &str, fact_type: Option<&str>, _verbose: bool) -> Result<types::DeleteResponse> {
pub fn clear_memories(
&self,
agent_id: &str,
fact_type: Option<&str>,
_verbose: bool,
) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.clear_bank_memories(agent_id, None, Some(fact_type)).await?;
let response = self
.client
.clear_bank_memories(agent_id, None, Some(fact_type))
.await?;
Ok(response.into_inner())
})
}
pub fn list_documents(&self, agent_id: &str, q: Option<&str>, limit: Option<i32>, offset: Option<i32>, _verbose: bool) -> Result<types::ListDocumentsResponse> {
pub fn list_documents(
&self,
agent_id: &str,
q: Option<&str>,
limit: Option<i32>,
offset: Option<i32>,
_verbose: bool,
) -> Result<types::ListDocumentsResponse> {
self.runtime.block_on(async {
let response = self.client.list_documents(
agent_id,
limit.map(|l| l as i64),
offset.map(|o| o as i64),
q,
None,
None,
None,
).await?;
let response = self
.client
.list_documents(
agent_id,
limit.map(|l| l as i64),
offset.map(|o| o as i64),
q,
None,
None,
None,
)
.await?;
Ok(response.into_inner())
})
}
pub fn get_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result<types::DocumentResponse> {
pub fn get_document(
&self,
agent_id: &str,
document_id: &str,
_verbose: bool,
) -> Result<types::DocumentResponse> {
self.runtime.block_on(async {
let response = self.client.get_document(agent_id, document_id, None).await?;
let response = self
.client
.get_document(agent_id, document_id, None)
.await?;
Ok(response.into_inner())
})
}
pub fn delete_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
pub fn delete_document(
&self,
agent_id: &str,
document_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.delete_document(agent_id, document_id, None).await?;
let response = self
.client
.delete_document(agent_id, document_id, None)
.await?;
let value = response.into_inner();
// Convert typed response to DeleteResponse
Ok(types::DeleteResponse {
@@ -329,7 +427,10 @@ impl ApiClient {
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
self.runtime.block_on(async {
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
let response = self
.client
.list_operations(agent_id, None, None, None, None, None)
.await?;
let value = response.into_inner();
// Convert to JSON Value first, then parse into our type
let json_value = serde_json::to_value(&value)?;
@@ -338,9 +439,17 @@ impl ApiClient {
})
}
pub fn cancel_operation(&self, agent_id: &str, operation_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
pub fn cancel_operation(
&self,
agent_id: &str,
operation_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.cancel_operation(agent_id, operation_id, None).await?;
let response = self
.client
.cancel_operation(agent_id, operation_id, None)
.await?;
let value = response.into_inner();
// Convert typed response to DeleteResponse
Ok(types::DeleteResponse {
@@ -351,30 +460,63 @@ impl ApiClient {
})
}
pub fn list_memories(&self, bank_id: &str, type_filter: Option<&str>, q: Option<&str>, limit: Option<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::ListMemoryUnitsResponse> {
pub fn list_memories(
&self,
bank_id: &str,
type_filter: Option<&str>,
q: Option<&str>,
limit: Option<i64>,
offset: Option<i64>,
_verbose: bool,
) -> Result<types::ListMemoryUnitsResponse> {
self.runtime.block_on(async {
let response = self.client.list_memories(bank_id, limit, offset, q, type_filter, None).await?;
let response = self
.client
.list_memories(bank_id, limit, offset, q, type_filter, None)
.await?;
Ok(response.into_inner())
})
}
pub fn list_entities(&self, bank_id: &str, limit: Option<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::EntityListResponse> {
pub fn list_entities(
&self,
bank_id: &str,
limit: Option<i64>,
offset: Option<i64>,
_verbose: bool,
) -> Result<types::EntityListResponse> {
self.runtime.block_on(async {
let response = self.client.list_entities(bank_id, limit, offset, None).await?;
let response = self
.client
.list_entities(bank_id, limit, offset, None)
.await?;
Ok(response.into_inner())
})
}
pub fn get_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result<types::EntityDetailResponse> {
pub fn get_entity(
&self,
bank_id: &str,
entity_id: &str,
_verbose: bool,
) -> Result<types::EntityDetailResponse> {
self.runtime.block_on(async {
let response = self.client.get_entity(bank_id, entity_id, None).await?;
Ok(response.into_inner())
})
}
pub fn regenerate_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result<types::EntityDetailResponse> {
pub fn regenerate_entity(
&self,
bank_id: &str,
entity_id: &str,
_verbose: bool,
) -> Result<types::EntityDetailResponse> {
self.runtime.block_on(async {
let response = self.client.regenerate_entity_observations(bank_id, entity_id, None).await?;
let response = self
.client
.regenerate_entity_observations(bank_id, entity_id, None)
.await?;
Ok(response.into_inner())
})
}
@@ -394,7 +536,12 @@ impl ApiClient {
impl ApiClient {
// --- Memory Methods ---
pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result<serde_json::Value> {
pub fn get_memory(
&self,
bank_id: &str,
memory_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.get_memory(bank_id, memory_id, None).await?;
Ok(response.into_inner())
@@ -410,7 +557,10 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::BankProfileResponse> {
self.runtime.block_on(async {
let response = self.client.create_or_update_bank(bank_id, None, request).await?;
let response = self
.client
.create_or_update_bank(bank_id, None, request)
.await?;
Ok(response.into_inner())
})
}
@@ -454,7 +604,10 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::GraphDataResponse> {
self.runtime.block_on(async {
let response = self.client.get_graph(bank_id, limit, type_filter, None, None, None, None).await?;
let response = self
.client
.get_graph(bank_id, limit, type_filter, None, None, None, None)
.await?;
Ok(response.into_inner())
})
}
@@ -478,9 +631,15 @@ impl ApiClient {
) -> Result<types::BankConfigResponse> {
self.runtime.block_on(async {
// Convert HashMap to serde_json::Map
let updates_map: serde_json::Map<String, serde_json::Value> = updates.into_iter().collect();
let request = types::BankConfigUpdate { updates: updates_map };
let response = self.client.update_bank_config(bank_id, None, &request).await?;
let updates_map: serde_json::Map<String, serde_json::Value> =
updates.into_iter().collect();
let request = types::BankConfigUpdate {
updates: updates_map,
};
let response = self
.client
.update_bank_config(bank_id, None, &request)
.await?;
Ok(response.into_inner())
})
}
@@ -507,7 +666,10 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::ListTagsResponse> {
self.runtime.block_on(async {
let response = self.client.list_tags(bank_id, limit, offset, q, None).await?;
let response = self
.client
.list_tags(bank_id, limit, offset, q, None)
.await?;
Ok(response.into_inner())
})
}
@@ -523,9 +685,17 @@ impl ApiClient {
// --- Operation Methods ---
pub fn get_operation(&self, bank_id: &str, operation_id: &str, _verbose: bool) -> Result<types::OperationStatusResponse> {
pub fn get_operation(
&self,
bank_id: &str,
operation_id: &str,
_verbose: bool,
) -> Result<types::OperationStatusResponse> {
self.runtime.block_on(async {
let response = self.client.get_operation_status(bank_id, operation_id, None).await?;
let response = self
.client
.get_operation_status(bank_id, operation_id, None)
.await?;
Ok(response.into_inner())
})
}
@@ -548,16 +718,31 @@ impl ApiClient {
// --- Mental Model Methods ---
pub fn list_mental_models(&self, bank_id: &str, _verbose: bool) -> Result<types::MentalModelListResponse> {
pub fn list_mental_models(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::MentalModelListResponse> {
self.runtime.block_on(async {
let response = self.client.list_mental_models(bank_id, None, None, None, None, None, None).await?;
let response = self
.client
.list_mental_models(bank_id, None, None, None, None, None, None)
.await?;
Ok(response.into_inner())
})
}
pub fn get_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::MentalModelResponse> {
pub fn get_mental_model(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<types::MentalModelResponse> {
self.runtime.block_on(async {
let response = self.client.get_mental_model(bank_id, mental_model_id, None, None).await?;
let response = self
.client
.get_mental_model(bank_id, mental_model_id, None, None)
.await?;
Ok(response.into_inner())
})
}
@@ -569,7 +754,10 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::CreateMentalModelResponse> {
self.runtime.block_on(async {
let response = self.client.create_mental_model(bank_id, None, request).await?;
let response = self
.client
.create_mental_model(bank_id, None, request)
.await?;
Ok(response.into_inner())
})
}
@@ -582,44 +770,86 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::MentalModelResponse> {
self.runtime.block_on(async {
let response = self.client.update_mental_model(bank_id, mental_model_id, None, request).await?;
let response = self
.client
.update_mental_model(bank_id, mental_model_id, None, request)
.await?;
Ok(response.into_inner())
})
}
pub fn delete_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
pub fn delete_mental_model(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.delete_mental_model(bank_id, mental_model_id, None).await?;
let response = self
.client
.delete_mental_model(bank_id, mental_model_id, None)
.await?;
Ok(response.into_inner())
})
}
pub fn refresh_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::AsyncOperationSubmitResponse> {
pub fn refresh_mental_model(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<types::AsyncOperationSubmitResponse> {
self.runtime.block_on(async {
let response = self.client.refresh_mental_model(bank_id, mental_model_id, None).await?;
let response = self
.client
.refresh_mental_model(bank_id, mental_model_id, None)
.await?;
Ok(response.into_inner())
})
}
pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
pub fn get_mental_model_history(
&self,
bank_id: &str,
mental_model_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?;
let response = self
.client
.get_mental_model_history(bank_id, mental_model_id, None)
.await?;
Ok(response.into_inner())
})
}
// --- Directive Methods ---
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
pub fn list_directives(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::DirectiveListResponse> {
self.runtime.block_on(async {
let response = self.client.list_directives(bank_id, None, None, None, None, None, None).await?;
let response = self
.client
.list_directives(bank_id, None, None, None, None, None, None)
.await?;
Ok(response.into_inner())
})
}
pub fn get_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<types::DirectiveResponse> {
pub fn get_directive(
&self,
bank_id: &str,
directive_id: &str,
_verbose: bool,
) -> Result<types::DirectiveResponse> {
self.runtime.block_on(async {
let response = self.client.get_directive(bank_id, directive_id, None).await?;
let response = self
.client
.get_directive(bank_id, directive_id, None)
.await?;
Ok(response.into_inner())
})
}
@@ -644,28 +874,47 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::DirectiveResponse> {
self.runtime.block_on(async {
let response = self.client.update_directive(bank_id, directive_id, None, request).await?;
let response = self
.client
.update_directive(bank_id, directive_id, None, request)
.await?;
Ok(response.into_inner())
})
}
pub fn delete_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<serde_json::Value> {
pub fn delete_directive(
&self,
bank_id: &str,
directive_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.delete_directive(bank_id, directive_id, None).await?;
let response = self
.client
.delete_directive(bank_id, directive_id, None)
.await?;
Ok(response.into_inner())
})
}
// --- Consolidation Methods ---
pub fn trigger_consolidation(&self, bank_id: &str, _verbose: bool) -> Result<types::ConsolidationResponse> {
pub fn trigger_consolidation(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::ConsolidationResponse> {
self.runtime.block_on(async {
let response = self.client.trigger_consolidation(bank_id, None).await?;
Ok(response.into_inner())
})
}
pub fn clear_observations(&self, bank_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
pub fn clear_observations(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self.client.clear_observations(bank_id, None).await?;
Ok(response.into_inner())
@@ -682,16 +931,291 @@ impl ApiClient {
}
}
// ============================================================================
// Webhooks, audit logs, bank templates, and other endpoints added for full
// OpenAPI coverage. Enforced by `uv run cli-coverage-check` in hindsight-dev.
// ============================================================================
impl ApiClient {
// --- Webhook Methods ---
pub fn list_webhooks(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::WebhookListResponse> {
self.runtime.block_on(async {
let response = self.client.list_webhooks(bank_id, None).await?;
Ok(response.into_inner())
})
}
pub fn create_webhook(
&self,
bank_id: &str,
request: &types::CreateWebhookRequest,
_verbose: bool,
) -> Result<types::WebhookResponse> {
self.runtime.block_on(async {
let response = self.client.create_webhook(bank_id, None, request).await?;
Ok(response.into_inner())
})
}
pub fn update_webhook(
&self,
bank_id: &str,
webhook_id: &str,
request: &types::UpdateWebhookRequest,
_verbose: bool,
) -> Result<types::WebhookResponse> {
self.runtime.block_on(async {
let response = self
.client
.update_webhook(bank_id, webhook_id, None, request)
.await?;
Ok(response.into_inner())
})
}
pub fn delete_webhook(
&self,
bank_id: &str,
webhook_id: &str,
_verbose: bool,
) -> Result<types::DeleteResponse> {
self.runtime.block_on(async {
let response = self
.client
.delete_webhook(bank_id, webhook_id, None)
.await?;
Ok(response.into_inner())
})
}
pub fn list_webhook_deliveries(
&self,
bank_id: &str,
webhook_id: &str,
cursor: Option<&str>,
limit: Option<i64>,
_verbose: bool,
) -> Result<types::WebhookDeliveryListResponse> {
self.runtime.block_on(async {
let response = self
.client
.list_webhook_deliveries(bank_id, webhook_id, cursor, limit, None)
.await?;
Ok(response.into_inner())
})
}
// --- Audit Log Methods ---
pub fn list_audit_logs(
&self,
bank_id: &str,
action: Option<&str>,
transport: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
limit: Option<u64>,
offset: Option<u64>,
_verbose: bool,
) -> Result<types::AuditLogListResponse> {
self.runtime.block_on(async {
let limit_nz = limit.and_then(std::num::NonZeroU64::new);
let response = self
.client
.list_audit_logs(
bank_id, action, end_date, limit_nz, offset, start_date, transport, None,
)
.await?;
Ok(response.into_inner())
})
}
pub fn audit_log_stats(
&self,
bank_id: &str,
action: Option<&str>,
period: Option<&str>,
_verbose: bool,
) -> Result<types::AuditLogStatsResponse> {
self.runtime.block_on(async {
let response = self
.client
.audit_log_stats(bank_id, action, period, None)
.await?;
Ok(response.into_inner())
})
}
// --- Bank Template Methods ---
pub fn get_bank_template_schema(&self, _verbose: bool) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self.client.get_bank_template_schema().await?;
Ok(response.into_inner())
})
}
pub fn export_bank_template(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::BankTemplateManifest> {
self.runtime.block_on(async {
let response = self.client.export_bank_template(bank_id, None).await?;
Ok(response.into_inner())
})
}
/// Import a bank template manifest. The OpenAPI spec does not declare a
/// request body for this endpoint, so the progenitor-generated client does
/// not expose one — we POST the manifest JSON via raw HTTP instead.
pub fn import_bank_template(
&self,
bank_id: &str,
manifest: &serde_json::Value,
dry_run: bool,
verbose: bool,
) -> Result<types::BankTemplateImportResponse> {
self.runtime.block_on(async {
let mut url = format!("{}/v1/default/banks/{}/import", self.base_url, bank_id);
if dry_run {
url.push_str("?dry_run=true");
}
if verbose {
eprintln!("POST {}", url);
}
let response = self.http_client.post(&url).json(manifest).send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!("Import failed ({}): {}", status, text);
}
let result: types::BankTemplateImportResponse = response.json().await?;
Ok(result)
})
}
// --- Document Methods ---
pub fn update_document(
&self,
bank_id: &str,
document_id: &str,
tags: Option<Vec<String>>,
_verbose: bool,
) -> Result<types::UpdateDocumentResponse> {
self.runtime.block_on(async {
let request = types::UpdateDocumentRequest { tags };
let response = self
.client
.update_document(bank_id, document_id, None, &request)
.await?;
Ok(response.into_inner())
})
}
// --- Memory Observation Methods ---
pub fn get_observation_history(
&self,
bank_id: &str,
memory_id: &str,
_verbose: bool,
) -> Result<serde_json::Value> {
self.runtime.block_on(async {
let response = self
.client
.get_observation_history(bank_id, memory_id, None)
.await?;
Ok(response.into_inner())
})
}
pub fn clear_memory_observations(
&self,
bank_id: &str,
memory_id: &str,
_verbose: bool,
) -> Result<types::ClearMemoryObservationsResponse> {
self.runtime.block_on(async {
let response = self
.client
.clear_memory_observations(bank_id, memory_id, None)
.await?;
Ok(response.into_inner())
})
}
// --- Operation Methods ---
pub fn retry_operation(
&self,
bank_id: &str,
operation_id: &str,
_verbose: bool,
) -> Result<types::RetryOperationResponse> {
self.runtime.block_on(async {
let response = self
.client
.retry_operation(bank_id, operation_id, None)
.await?;
Ok(response.into_inner())
})
}
// --- Consolidation Recovery ---
pub fn recover_consolidation(
&self,
bank_id: &str,
_verbose: bool,
) -> Result<types::RecoverConsolidationResponse> {
self.runtime.block_on(async {
let response = self.client.recover_consolidation(bank_id, None).await?;
Ok(response.into_inner())
})
}
// --- Bank Disposition ---
pub fn update_bank_disposition(
&self,
bank_id: &str,
skepticism: u64,
literalism: u64,
empathy: u64,
_verbose: bool,
) -> Result<types::BankProfileResponse> {
self.runtime.block_on(async {
let to_nz = |v: u64| -> Result<std::num::NonZeroU64> {
std::num::NonZeroU64::new(v)
.ok_or_else(|| anyhow::anyhow!("disposition traits must be 1-5"))
};
let request = types::UpdateDispositionRequest {
disposition: types::DispositionTraits {
skepticism: to_nz(skepticism)?,
literalism: to_nz(literalism)?,
empathy: to_nz(empathy)?,
},
};
let response = self
.client
.update_bank_disposition(bank_id, None, &request)
.await?;
Ok(response.into_inner())
})
}
}
// Re-export types from the generated client for use in commands
pub use types::{
BankProfileResponse,
MemoryItem,
RecallRequest,
RecallResponse,
RecallResult,
ReflectRequest,
ReflectResponse,
RetainRequest,
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
ReflectResponse, RetainRequest,
};
#[cfg(test)]
+118
View File
@@ -0,0 +1,118 @@
//! Audit log commands.
use anyhow::Result;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
/// List audit log entries for a bank
#[allow(clippy::too_many_arguments)]
pub fn list(
client: &ApiClient,
bank_id: &str,
action: Option<String>,
transport: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
limit: Option<u64>,
offset: Option<u64>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching audit logs..."))
} else {
None
};
let response = client.list_audit_logs(
bank_id,
action.as_deref(),
transport.as_deref(),
start_date.as_deref(),
end_date.as_deref(),
limit,
offset,
verbose,
);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Audit logs: {}", bank_id));
println!(
" {} {} ({} total)",
ui::dim("Showing:"),
result.items.len(),
result.total
);
println!();
if result.items.is_empty() {
println!(" {}", ui::dim("No audit log entries."));
} else {
for entry in &result.items {
let started = entry.started_at.as_deref().unwrap_or("-");
let duration = entry
.duration_ms
.map(|d| format!("{}ms", d))
.unwrap_or_else(|| "-".to_string());
println!(
" {} {} [{}] {}",
ui::dim(started),
ui::gradient_start(&entry.action),
entry.transport,
duration
);
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
/// Get audit log statistics for a bank
pub fn stats(
client: &ApiClient,
bank_id: &str,
action: Option<String>,
period: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching audit log stats..."))
} else {
None
};
let response = client.audit_log_stats(bank_id, action.as_deref(), period.as_deref(), verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Audit stats: {}", bank_id));
println!(" {} {}", ui::dim("Period:"), result.period);
println!(" {} {}", ui::dim("Start:"), result.start);
println!(" {} {}", ui::dim("Bucket:"), result.trunc);
println!();
if result.buckets.is_empty() {
println!(" {}", ui::dim("No activity in this period."));
} else {
for bucket in &result.buckets {
let json = serde_json::to_value(bucket)?;
println!(" {}", json);
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
+342 -46
View File
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Result};
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use anyhow::{anyhow, Result};
pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
@@ -32,11 +32,16 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
pub fn disposition(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching disposition..."))
} else {
@@ -58,11 +63,16 @@ pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_form
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
pub fn stats(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching statistics..."))
} else {
@@ -80,9 +90,21 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Statistics: {}", bank_id));
println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string()));
println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string()));
println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string()));
println!(
" {} {}",
ui::dim("memory units:"),
ui::gradient_start(&stats.total_nodes.to_string())
);
println!(
" {} {}",
ui::dim("links:"),
ui::gradient_mid(&stats.total_links.to_string())
);
println!(
" {} {}",
ui::dim("documents:"),
ui::gradient_end(&stats.total_documents.to_string())
);
println!();
println!("{}", ui::gradient_text("─── Memory Units by Type ───"));
@@ -90,7 +112,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
fact_types.sort_by_key(|(k, _)| *k);
for (i, (fact_type, count)) in fact_types.iter().enumerate() {
let t = i as f32 / fact_types.len().max(1) as f32;
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
println!(
" {:<10} {}",
fact_type,
ui::gradient(&count.to_string(), t)
);
}
println!();
@@ -99,7 +125,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
link_types.sort_by_key(|(k, _)| *k);
for (i, (link_type, count)) in link_types.iter().enumerate() {
let t = i as f32 / link_types.len().max(1) as f32;
println!(" {:<10} {}", link_type, ui::gradient(&count.to_string(), t));
println!(
" {:<10} {}",
link_type,
ui::gradient(&count.to_string(), t)
);
}
println!();
@@ -108,7 +138,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
fact_type_links.sort_by_key(|(k, _)| *k);
for (i, (fact_type, count)) in fact_type_links.iter().enumerate() {
let t = i as f32 / fact_type_links.len().max(1) as f32;
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
println!(
" {:<10} {}",
fact_type,
ui::gradient(&count.to_string(), t)
);
}
println!();
@@ -141,11 +175,17 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
pub fn update_name(
client: &ApiClient,
bank_id: &str,
name: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating bank name..."))
} else {
@@ -167,7 +207,7 @@ pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool,
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -177,7 +217,7 @@ pub fn update_background(
content: &str,
no_update_disposition: bool,
verbose: bool,
output_format: OutputFormat
output_format: OutputFormat,
) -> Result<()> {
let current_profile = if !no_update_disposition {
client.get_profile(bank_id, verbose).ok()
@@ -204,9 +244,10 @@ pub fn update_background(
println!("\n{}", profile.mission);
if !no_update_disposition {
if let (Some(old_p), Some(new_p)) =
(current_profile.as_ref().map(|p| p.disposition.clone()), &profile.disposition)
{
if let (Some(old_p), Some(new_p)) = (
current_profile.as_ref().map(|p| p.disposition.clone()),
&profile.disposition,
) {
println!("\nDisposition changes:");
println!(" Skepticism: {}{}", old_p.skepticism, new_p.skepticism);
println!(" Literalism: {}{}", old_p.literalism, new_p.literalism);
@@ -218,7 +259,7 @@ pub fn update_background(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -329,7 +370,12 @@ pub fn update(
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() && mission_text.is_none() && skepticism.is_none() && literalism.is_none() && empathy.is_none() {
if name.is_none()
&& mission_text.is_none()
&& skepticism.is_none()
&& literalism.is_none()
&& empathy.is_none()
{
anyhow::bail!("At least one field must be provided (--name, --mission, --skepticism, --literalism, --empathy)");
}
@@ -407,20 +453,27 @@ pub fn graph(
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Memory Graph: {}", bank_id));
println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string()));
println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string()));
println!(
" {} {}",
ui::dim("Nodes:"),
ui::gradient_start(&result.nodes.len().to_string())
);
println!(
" {} {}",
ui::dim("Edges:"),
ui::gradient_end(&result.edges.len().to_string())
);
println!();
// Show sample of nodes
if !result.nodes.is_empty() {
println!("{}", ui::gradient_text("─── Sample Nodes ───"));
for node in result.nodes.iter().take(5) {
let fact_type = node.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let id = node.get("id")
let fact_type = node
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let id = node.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
println!(" {} [{}]", ui::dim(id), fact_type);
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
let preview: String = text.chars().take(60).collect();
@@ -429,12 +482,18 @@ pub fn graph(
}
}
if result.nodes.len() > 5 {
println!(" {} more...", ui::dim(&format!("+ {}", result.nodes.len() - 5)));
println!(
" {} more...",
ui::dim(&format!("+ {}", result.nodes.len() - 5))
);
}
println!();
}
println!("{}", ui::dim("Use JSON output for full graph data: -o json"));
println!(
"{}",
ui::dim("Use JSON output for full graph data: -o json")
);
} else {
output::print_output(&result, output_format)?;
}
@@ -449,7 +508,7 @@ pub fn delete(
bank_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat
output_format: OutputFormat,
) -> Result<()> {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
@@ -494,7 +553,7 @@ pub fn delete(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -527,7 +586,11 @@ pub fn consolidate(
ui::print_success("Consolidation triggered");
println!(" {} {}", ui::dim("Operation ID:"), operation_id);
if result.deduplicated {
println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task");
println!(
" {} {}",
ui::dim("Note:"),
"Reusing existing pending consolidation task"
);
}
} else {
output::print_output(&result, output_format)?;
@@ -544,7 +607,13 @@ pub fn consolidate(
// Poll for completion
if output_format == OutputFormat::Pretty {
println!();
println!("{}", ui::dim(&format!("Polling every {}s for completion...", poll_interval)));
println!(
"{}",
ui::dim(&format!(
"Polling every {}s for completion...",
poll_interval
))
);
}
let start = std::time::Instant::now();
@@ -561,7 +630,10 @@ pub fn consolidate(
match op.map(|o| o.status.as_str()) {
Some("completed") => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Consolidation completed ({}s)", elapsed));
ui::print_success(&format!(
"Consolidation completed ({}s)",
elapsed
));
}
break;
}
@@ -571,7 +643,10 @@ pub fn consolidate(
.map(|s| s.as_str())
.unwrap_or("Unknown error");
if output_format == OutputFormat::Pretty {
ui::print_error(&format!("Consolidation failed: {}", error_msg));
ui::print_error(&format!(
"Consolidation failed: {}",
error_msg
));
}
std::process::exit(1);
}
@@ -582,7 +657,10 @@ pub fn consolidate(
}
None => {
if output_format == OutputFormat::Pretty {
ui::print_warning(&format!("Operation {} not found in list", operation_id));
ui::print_warning(&format!(
"Operation {} not found in list",
operation_id
));
}
break;
}
@@ -732,37 +810,67 @@ pub fn set_config(
let mut updates: HashMap<String, serde_json::Value> = HashMap::new();
if let Some(provider) = llm_provider {
updates.insert("llm_provider".to_string(), serde_json::Value::String(provider));
updates.insert(
"llm_provider".to_string(),
serde_json::Value::String(provider),
);
}
if let Some(model) = llm_model {
updates.insert("llm_model".to_string(), serde_json::Value::String(model));
}
if let Some(api_key) = llm_api_key {
updates.insert("llm_api_key".to_string(), serde_json::Value::String(api_key));
updates.insert(
"llm_api_key".to_string(),
serde_json::Value::String(api_key),
);
}
if let Some(base_url) = llm_base_url {
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
updates.insert(
"llm_base_url".to_string(),
serde_json::Value::String(base_url),
);
}
if let Some(mission) = retain_mission {
updates.insert("retain_mission".to_string(), serde_json::Value::String(mission));
updates.insert(
"retain_mission".to_string(),
serde_json::Value::String(mission),
);
}
if let Some(mode) = retain_extraction_mode {
updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode));
updates.insert(
"retain_extraction_mode".to_string(),
serde_json::Value::String(mode),
);
}
if let Some(mission) = observations_mission {
updates.insert("observations_mission".to_string(), serde_json::Value::String(mission));
updates.insert(
"observations_mission".to_string(),
serde_json::Value::String(mission),
);
}
if let Some(mission) = reflect_mission {
updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission));
updates.insert(
"reflect_mission".to_string(),
serde_json::Value::String(mission),
);
}
if let Some(skepticism) = disposition_skepticism {
updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into()));
updates.insert(
"disposition_skepticism".to_string(),
serde_json::Value::Number(skepticism.into()),
);
}
if let Some(literalism) = disposition_literalism {
updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into()));
updates.insert(
"disposition_literalism".to_string(),
serde_json::Value::Number(literalism.into()),
);
}
if let Some(empathy) = disposition_empathy {
updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into()));
updates.insert(
"disposition_empathy".to_string(),
serde_json::Value::Number(empathy.into()),
);
}
if updates.is_empty() {
@@ -832,7 +940,10 @@ pub fn reset_config(
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Configuration reset to defaults for bank '{}'", bank_id));
ui::print_success(&format!(
"Configuration reset to defaults for bank '{}'",
bank_id
));
} else {
output::print_output(&result, output_format)?;
}
@@ -841,3 +952,188 @@ pub fn reset_config(
Err(e) => Err(e),
}
}
/// Set disposition traits (skepticism, literalism, empathy) via PUT /profile
pub fn set_disposition(
client: &ApiClient,
bank_id: &str,
skepticism: u64,
literalism: u64,
empathy: u64,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating disposition..."))
} else {
None
};
let response =
client.update_bank_disposition(bank_id, skepticism, literalism, empathy, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let profile = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Disposition updated for bank '{}'", bank_id));
ui::print_disposition(&profile);
} else {
output::print_output(&profile, output_format)?;
}
Ok(())
}
/// Recover from a stalled consolidation
pub fn consolidation_recover(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Recovering consolidation..."))
} else {
None
};
let response = client.recover_consolidation(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Consolidation recovered for bank '{}'", bank_id));
let json = serde_json::to_value(&result)?;
println!(
" {}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
/// Export a bank template manifest (bank config + mental models + directives)
pub fn export_template(
client: &ApiClient,
bank_id: &str,
out_path: Option<std::path::PathBuf>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Exporting bank template..."))
} else {
None
};
let response = client.export_bank_template(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let manifest = response?;
let json = serde_json::to_string_pretty(&manifest)?;
if let Some(path) = out_path {
std::fs::write(&path, &json)
.map_err(|e| anyhow!("Failed to write {}: {}", path.display(), e))?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Template written to {}", path.display()));
}
} else if output_format == OutputFormat::Pretty {
println!("{}", json);
} else {
output::print_output(&manifest, output_format)?;
}
Ok(())
}
/// Import a bank template manifest from a JSON file
pub fn import_template(
client: &ApiClient,
bank_id: &str,
manifest_path: &std::path::Path,
dry_run: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let raw = std::fs::read_to_string(manifest_path)
.map_err(|e| anyhow!("Failed to read {}: {}", manifest_path.display(), e))?;
let manifest: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| anyhow!("Invalid JSON in {}: {}", manifest_path.display(), e))?;
let spinner = if output_format == OutputFormat::Pretty {
let msg = if dry_run {
"Validating bank template (dry run)..."
} else {
"Importing bank template..."
};
Some(ui::create_spinner(msg))
} else {
None
};
let response = client.import_bank_template(bank_id, &manifest, dry_run, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
if dry_run {
ui::print_success(&format!("Template for bank '{}' validated", bank_id));
} else {
ui::print_success(&format!("Template imported into bank '{}'", bank_id));
}
println!(" directives created: {:?}", result.directives_created);
println!(" directives updated: {:?}", result.directives_updated);
println!(
" mental models created: {:?}",
result.mental_models_created
);
println!(
" mental models updated: {:?}",
result.mental_models_updated
);
println!(" config applied: {}", result.config_applied);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
/// Fetch the bank template JSON schema
pub fn template_schema(
client: &ApiClient,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching template schema..."))
} else {
None
};
let response = client.get_bank_template_schema(verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let schema = response?;
if output_format == OutputFormat::Pretty {
println!("{}", serde_json::to_string_pretty(&schema)?);
} else {
output::print_output(&schema, output_format)?;
}
Ok(())
}
+10 -4
View File
@@ -99,11 +99,13 @@ pub fn get(
}
/// Create a new directive
#[allow(clippy::too_many_arguments)]
pub fn create(
client: &ApiClient,
bank_id: &str,
name: &str,
content: &str,
priority: i64,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -117,7 +119,7 @@ pub fn create(
name: name.to_string(),
content: content.to_string(),
is_active: true,
priority: 0,
priority,
tags: vec![],
};
@@ -143,6 +145,7 @@ pub fn create(
}
/// Update a directive
#[allow(clippy::too_many_arguments)]
pub fn update(
client: &ApiClient,
bank_id: &str,
@@ -150,11 +153,14 @@ pub fn update(
name: Option<String>,
content: Option<String>,
is_active: Option<bool>,
priority: Option<i64>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() && content.is_none() && is_active.is_none() {
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
if name.is_none() && content.is_none() && is_active.is_none() && priority.is_none() {
anyhow::bail!(
"At least one of --name, --content, --is-active, or --priority must be provided"
);
}
let spinner = if output_format == OutputFormat::Pretty {
@@ -167,7 +173,7 @@ pub fn update(
name,
content,
is_active,
priority: None,
priority,
tags: None,
};
+73 -15
View File
@@ -1,9 +1,9 @@
use anyhow::Result;
use chrono::{Duration as ChronoDuration, NaiveDate, Utc};
use std::collections::BTreeMap;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use anyhow::Result;
use chrono::{Duration as ChronoDuration, NaiveDate, Utc};
use std::collections::BTreeMap;
pub fn list(
client: &ApiClient,
@@ -26,7 +26,13 @@ pub fn list(
None
};
let response = client.list_documents(agent_id, query.as_deref(), Some(limit), Some(offset), verbose);
let response = client.list_documents(
agent_id,
query.as_deref(),
Some(limit),
Some(offset),
verbose,
);
if let Some(mut sp) = spinner {
sp.finish();
@@ -35,13 +41,25 @@ pub fn list(
match response {
Ok(docs_response) => {
if output_format == OutputFormat::Pretty {
ui::print_info(&format!("Documents for bank '{}' (total: {})", agent_id, docs_response.total));
ui::print_info(&format!(
"Documents for bank '{}' (total: {})",
agent_id, docs_response.total
));
for doc in &docs_response.items {
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let created = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("unknown");
let updated = doc.get("updated_at").and_then(|v| v.as_str()).unwrap_or("unknown");
let created = doc
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let updated = doc
.get("updated_at")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let text_len = doc.get("text_length").and_then(|v| v.as_i64()).unwrap_or(0);
let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0);
let mem_count = doc
.get("memory_unit_count")
.and_then(|v| v.as_i64())
.unwrap_or(0);
println!("\n Document ID: {}", id);
println!(" Created: {}", created);
@@ -54,7 +72,7 @@ pub fn list(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -87,9 +105,7 @@ fn list_with_date(
let mut filtered_count = 0;
for doc in all_docs {
let created_at = doc.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("");
let created_at = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
// Parse the date part (YYYY-MM-DD) from created_at
let doc_date = created_at.split('T').next().unwrap_or("");
@@ -126,7 +142,10 @@ fn list_with_date(
println!(" {} ({} documents)", date_str, docs.len());
for doc in docs {
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0);
let mem_count = doc
.get("memory_unit_count")
.and_then(|v| v.as_i64())
.unwrap_or(0);
println!(" - {} ({} memories)", id, mem_count);
}
println!();
@@ -224,7 +243,7 @@ pub fn get(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -260,6 +279,45 @@ pub fn delete(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
/// Update a document (currently only supports replacing tags)
pub fn update(
client: &ApiClient,
bank_id: &str,
document_id: &str,
tags: Option<Vec<String>>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if tags.is_none() {
anyhow::bail!("At least one of --tags must be provided");
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating document..."))
} else {
None
};
let response = client.update_document(bank_id, document_id, tags, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Document '{}' updated", document_id));
let json = serde_json::to_value(&result)?;
println!(
" {}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
+167 -36
View File
@@ -3,13 +3,16 @@ use std::fs;
use std::path::PathBuf;
use walkdir::WalkDir;
use crate::api::{ApiClient, RecallRequest, ReflectRequest, MemoryItem, RetainRequest};
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
use crate::config;
use crate::output::{self, OutputFormat};
use crate::ui;
// Import types from generated client
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
use hindsight_client::types::{
Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions,
TagsMatch,
};
use serde::Deserialize;
use serde_json;
@@ -45,7 +48,12 @@ fn parse_budget(budget: &str) -> Budget {
// Helper function to parse tags_match string to TagsMatch enum
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() {
match tags_match
.as_deref()
.unwrap_or("any")
.to_lowercase()
.as_str()
{
"all" => TagsMatch::All,
"any_strict" => TagsMatch::AnyStrict,
"all_strict" => TagsMatch::AllStrict,
@@ -86,13 +94,19 @@ pub fn list(
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Memories: {} (showing {}-{})", bank_id, offset + 1, offset + result.items.len() as i64));
ui::print_section_header(&format!(
"Memories: {} (showing {}-{})",
bank_id,
offset + 1,
offset + result.items.len() as i64
));
if result.items.is_empty() {
println!(" {}", ui::dim("No memories found."));
} else {
for item in &result.items {
let fact_type = item.get("type")
let fact_type = item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let type_t = match fact_type {
@@ -102,9 +116,7 @@ pub fn list(
_ => 0.5,
};
let id = item.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
println!(
" {} {}",
@@ -172,7 +184,11 @@ pub fn get(
ui::print_section_header(&format!("Memory: {}", memory_id));
println!(" {} {}", ui::dim("Type:"), ui::gradient(&fact_type.to_uppercase(), type_t));
println!(
" {} {}",
ui::dim("Type:"),
ui::gradient(&fact_type.to_uppercase(), type_t)
);
println!(" {} {}", ui::dim("ID:"), result.id);
if let Some(doc_id) = &result.document_id {
@@ -234,12 +250,9 @@ pub fn get(
fn is_supported_file(path: &std::path::Path) -> bool {
const SUPPORTED_EXTENSIONS: &[&str] = &[
// Documents
"pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls",
// Images (OCR)
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff",
// Web / markup
"html", "htm",
// Text / data
"pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", // Images (OCR)
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", // Web / markup
"html", "htm", // Text / data
"txt", "md", "csv", "json", "yaml", "yml", "toml", "xml", "rst", "adoc", "log",
// Audio (transcription)
"mp3", "wav", "ogg", "flac",
@@ -250,6 +263,7 @@ fn is_supported_file(path: &std::path::Path) -> bool {
.unwrap_or(false)
}
#[allow(clippy::too_many_arguments)]
pub fn recall(
client: &ApiClient,
agent_id: &str,
@@ -262,6 +276,7 @@ pub fn recall(
chunk_max_tokens: i64,
tags: Vec<String>,
tags_match: Option<String>,
query_timestamp: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -286,11 +301,15 @@ pub fn recall(
let request = RecallRequest {
query,
types: if fact_type.is_empty() { None } else { Some(fact_type) },
types: if fact_type.is_empty() {
None
} else {
Some(fact_type)
},
budget: Some(parse_budget(&budget)),
max_tokens,
trace,
query_timestamp: None,
query_timestamp,
include,
tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match),
@@ -312,10 +331,11 @@ pub fn recall(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
#[allow(clippy::too_many_arguments)]
pub fn reflect(
client: &ApiClient,
agent_id: &str,
@@ -327,6 +347,9 @@ pub fn reflect(
tags: Vec<String>,
tags_match: Option<String>,
include_facts: bool,
fact_types: Option<Vec<String>>,
exclude_mental_models: bool,
exclude_mental_model_ids: Option<Vec<String>>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -340,8 +363,9 @@ pub fn reflect(
let response_schema = if let Some(path) = schema_path {
let schema_content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read schema file: {}", path.display()))?;
let schema: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&schema_content)
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
let schema: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(&schema_content)
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
Some(schema)
} else {
None
@@ -356,6 +380,21 @@ pub fn reflect(
None
};
// Map the CLI fact-type strings (world, experience, observation) into the
// generated FactTypesItem enum. Unknown values are dropped — the server
// would reject them anyway.
let mapped_fact_types = fact_types.as_ref().map(|types| {
types
.iter()
.filter_map(|t| match t.to_lowercase().as_str() {
"world" => Some(hindsight_client::types::FactTypesItem::World),
"experience" => Some(hindsight_client::types::FactTypesItem::Experience),
"observation" => Some(hindsight_client::types::FactTypesItem::Observation),
_ => None,
})
.collect::<Vec<_>>()
});
let request = ReflectRequest {
query,
budget: Some(parse_budget(&budget)),
@@ -366,9 +405,9 @@ pub fn reflect(
tags: if tags.is_empty() { None } else { Some(tags) },
tags_match: parse_tags_match(&tags_match),
tag_groups: None,
fact_types: None,
exclude_mental_models: false,
exclude_mental_model_ids: None,
fact_types: mapped_fact_types,
exclude_mental_models,
exclude_mental_model_ids,
};
let response = client.reflect(agent_id, &request, verbose);
@@ -386,10 +425,11 @@ pub fn reflect(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
#[allow(clippy::too_many_arguments)]
pub fn retain(
client: &ApiClient,
agent_id: &str,
@@ -397,6 +437,7 @@ pub fn retain(
doc_id: Option<String>,
context: Option<String>,
r#async: bool,
document_tags: Option<Vec<String>>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -418,12 +459,13 @@ pub fn retain(
tags: None,
observation_scopes: None,
strategy: None,
update_mode: None,
};
let request = RetainRequest {
items: vec![item],
async_: r#async,
document_tags: None,
document_tags,
};
let response = client.retain(agent_id, &request, r#async, verbose);
@@ -450,7 +492,7 @@ pub fn retain(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -617,7 +659,7 @@ pub fn delete(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -687,10 +729,85 @@ pub fn clear(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
/// Get the observation history for a memory unit
pub fn history(
client: &ApiClient,
bank_id: &str,
memory_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching observation history..."))
} else {
None
};
let response = client.get_observation_history(bank_id, memory_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
/// Clear the observations attached to a specific memory unit
pub fn clear_observations(
client: &ApiClient,
bank_id: &str,
memory_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if !yes && output_format == OutputFormat::Pretty {
let msg = format!(
"Clear observations for memory '{}'? They will be re-derived on next consolidation.",
memory_id
);
if !ui::prompt_confirmation(&msg)? {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Clearing observations..."))
} else {
None
};
let response = client.clear_memory_observations(bank_id, memory_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Cleared observations for memory '{}'", memory_id));
let json = serde_json::to_value(&result)?;
println!(
" {}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -699,8 +816,17 @@ mod tests {
#[test]
fn test_is_supported_file_text_extensions() {
let supported = [
"file.txt", "file.md", "file.json", "file.yaml", "file.yml",
"file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc",
"file.txt",
"file.md",
"file.json",
"file.yaml",
"file.yml",
"file.toml",
"file.xml",
"file.csv",
"file.log",
"file.rst",
"file.adoc",
];
for filename in supported {
assert!(
@@ -714,9 +840,16 @@ mod tests {
#[test]
fn test_is_supported_file_binary_extensions() {
let supported = [
"file.pdf", "file.docx", "file.pptx", "file.xlsx",
"file.png", "file.jpg", "file.jpeg", "file.gif",
"file.mp3", "file.wav",
"file.pdf",
"file.docx",
"file.pptx",
"file.xlsx",
"file.png",
"file.jpg",
"file.jpeg",
"file.gif",
"file.mp3",
"file.wav",
];
for filename in supported {
assert!(
@@ -738,9 +871,7 @@ mod tests {
#[test]
fn test_is_supported_file_unsupported_extensions() {
let unsupported = [
"file.exe", "file.bin", "file.zip", "file.tar", "file.gz",
];
let unsupported = ["file.exe", "file.bin", "file.zip", "file.tar", "file.gz"];
for filename in unsupported {
assert!(
!is_supported_file(Path::new(filename)),
+52 -9
View File
@@ -95,12 +95,16 @@ pub fn get(
}
/// Create a new mental model
#[allow(clippy::too_many_arguments)]
pub fn create(
client: &ApiClient,
bank_id: &str,
name: &str,
source_query: &str,
id: Option<&str>,
tags: Vec<String>,
max_tokens: i64,
trigger_refresh_after_consolidation: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -110,13 +114,28 @@ pub fn create(
None
};
// Only send a trigger when the user opted in, so the server's default
// behaviour is preserved otherwise.
let trigger = if trigger_refresh_after_consolidation {
Some(types::MentalModelTriggerInput {
refresh_after_consolidation: true,
exclude_mental_models: false,
exclude_mental_model_ids: None,
fact_types: None,
tag_groups: None,
tags_match: None,
})
} else {
None
};
let request = types::CreateMentalModelRequest {
id: id.map(|s| s.to_string()),
name: name.to_string(),
source_query: source_query.to_string(),
max_tokens: 2048,
tags: vec![],
trigger: None,
max_tokens,
tags,
trigger,
};
let response = client.create_mental_model(bank_id, &request, verbose);
@@ -139,16 +158,29 @@ pub fn create(
}
/// Update a mental model
#[allow(clippy::too_many_arguments)]
pub fn update(
client: &ApiClient,
bank_id: &str,
mental_model_id: &str,
name: Option<String>,
source_query: Option<String>,
max_tokens: Option<i64>,
tags: Option<Vec<String>>,
trigger_refresh_after_consolidation: Option<bool>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if name.is_none() {
anyhow::bail!("--name must be provided");
if name.is_none()
&& source_query.is_none()
&& max_tokens.is_none()
&& tags.is_none()
&& trigger_refresh_after_consolidation.is_none()
{
anyhow::bail!(
"At least one of --name, --source-query, --max-tokens, --tags, or \
--trigger-refresh-after-consolidation must be provided"
);
}
let spinner = if output_format == OutputFormat::Pretty {
@@ -157,12 +189,23 @@ pub fn update(
None
};
// Only build a trigger override when the user actually passed the flag;
// sending None leaves the existing trigger config untouched on the server.
let trigger = trigger_refresh_after_consolidation.map(|refresh| types::MentalModelTriggerInput {
refresh_after_consolidation: refresh,
exclude_mental_models: false,
exclude_mental_model_ids: None,
fact_types: None,
tag_groups: None,
tags_match: None,
});
let request = types::UpdateMentalModelRequest {
name,
source_query: None,
max_tokens: None,
tags: None,
trigger: None,
source_query,
max_tokens,
tags,
trigger,
};
let response = client.update_mental_model(bank_id, mental_model_id, &request, verbose);
+3 -1
View File
@@ -1,3 +1,4 @@
pub mod audit;
pub mod bank;
pub mod chunk;
pub mod directive;
@@ -6,6 +7,7 @@ pub mod entity;
pub mod explore;
pub mod health;
pub mod memory;
pub mod operation;
pub mod mental_model;
pub mod operation;
pub mod tag;
pub mod webhook;
+41 -4
View File
@@ -1,7 +1,7 @@
use anyhow::Result;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use anyhow::Result;
pub fn list(
client: &ApiClient,
@@ -27,7 +27,10 @@ pub fn list(
if ops_response.operations.is_empty() {
ui::print_info("No operations found");
} else {
ui::print_info(&format!("Found {} operation(s)", ops_response.operations.len()));
ui::print_info(&format!(
"Found {} operation(s)",
ops_response.operations.len()
));
for op in &ops_response.operations {
println!("\n Operation ID: {}", op.id);
println!(" Type: {}", op.task_type);
@@ -43,7 +46,7 @@ pub fn list(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
@@ -128,6 +131,40 @@ pub fn cancel(
}
Ok(())
}
Err(e) => Err(e)
Err(e) => Err(e),
}
}
/// Retry a failed async operation
pub fn retry(
client: &ApiClient,
agent_id: &str,
operation_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Retrying operation..."))
} else {
None
};
let response = client.retry_operation(agent_id, operation_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Operation '{}' retried", operation_id));
let json = serde_json::to_value(&result)?;
println!(
" {}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
+249
View File
@@ -0,0 +1,249 @@
//! Webhook commands for managing event delivery hooks.
use anyhow::Result;
use crate::api::ApiClient;
use crate::output::{self, OutputFormat};
use crate::ui;
use hindsight_client::types;
/// List webhooks for a bank
pub fn list(
client: &ApiClient,
bank_id: &str,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching webhooks..."))
} else {
None
};
let response = client.list_webhooks(bank_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Webhooks: {}", bank_id));
if result.items.is_empty() {
println!(" {}", ui::dim("No webhooks configured."));
} else {
for wh in &result.items {
let status = if wh.enabled {
ui::gradient_start("enabled")
} else {
ui::dim("disabled")
};
println!(" {} [{}] {}", ui::gradient_start(&wh.id), status, wh.url);
if !wh.event_types.is_empty() {
println!(" events: {}", wh.event_types.join(", "));
}
println!();
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
/// Create a new webhook
#[allow(clippy::too_many_arguments)]
pub fn create(
client: &ApiClient,
bank_id: &str,
url: &str,
event_types: Vec<String>,
enabled: bool,
secret: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Creating webhook..."))
} else {
None
};
let effective_events = if event_types.is_empty() {
vec!["consolidation.completed".to_string()]
} else {
event_types
};
let request = types::CreateWebhookRequest {
enabled,
event_types: effective_events,
http_config: None,
secret,
url: url.to_string(),
};
let response = client.create_webhook(bank_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let wh = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Webhook '{}' created", wh.id));
println!(" URL: {}", wh.url);
println!(" Events: {}", wh.event_types.join(", "));
} else {
output::print_output(&wh, output_format)?;
}
Ok(())
}
/// Update a webhook
#[allow(clippy::too_many_arguments)]
pub fn update(
client: &ApiClient,
bank_id: &str,
webhook_id: &str,
url: Option<String>,
event_types: Option<Vec<String>>,
enabled: Option<bool>,
secret: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if url.is_none() && event_types.is_none() && enabled.is_none() && secret.is_none() {
anyhow::bail!(
"At least one of --url, --event-types, --enabled, or --secret must be provided"
);
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating webhook..."))
} else {
None
};
let request = types::UpdateWebhookRequest {
enabled,
event_types,
http_config: None,
secret,
url,
};
let response = client.update_webhook(bank_id, webhook_id, &request, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let wh = response?;
if output_format == OutputFormat::Pretty {
ui::print_success(&format!("Webhook '{}' updated", wh.id));
} else {
output::print_output(&wh, output_format)?;
}
Ok(())
}
/// Delete a webhook
pub fn delete(
client: &ApiClient,
bank_id: &str,
webhook_id: &str,
yes: bool,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
if !yes && output_format == OutputFormat::Pretty {
let message = format!(
"Are you sure you want to delete webhook '{}'? This cannot be undone.",
webhook_id
);
if !ui::prompt_confirmation(&message)? {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Deleting webhook..."))
} else {
None
};
let response = client.delete_webhook(bank_id, webhook_id, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
if result.success {
ui::print_success(&format!("Webhook '{}' deleted", webhook_id));
} else {
ui::print_error("Failed to delete webhook");
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
/// List recent delivery attempts for a webhook
pub fn deliveries(
client: &ApiClient,
bank_id: &str,
webhook_id: &str,
cursor: Option<String>,
limit: Option<i64>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Fetching deliveries..."))
} else {
None
};
let response =
client.list_webhook_deliveries(bank_id, webhook_id, cursor.as_deref(), limit, verbose);
if let Some(mut sp) = spinner {
sp.finish();
}
let result = response?;
if output_format == OutputFormat::Pretty {
ui::print_section_header(&format!("Deliveries for {}", webhook_id));
if result.items.is_empty() {
println!(" {}", ui::dim("No delivery attempts recorded."));
} else {
for d in &result.items {
println!(
" {} [{}] {} — attempts: {}",
ui::gradient_start(&d.id),
d.event_type,
d.last_response_status
.map(|s| s.to_string())
.unwrap_or_else(|| "-".to_string()),
d.attempts
);
if let Some(err) = &d.last_error {
println!(" {} {}", ui::dim("error:"), err);
}
}
if let Some(cursor) = &result.next_cursor {
println!();
println!(" {} {}", ui::dim("next cursor:"), cursor);
}
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
+863 -88
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.4.22
version: 0.5.0
servers:
- url: /
paths:
@@ -3570,7 +3570,7 @@ components:
type: integer
entity_labels:
items:
type: string
additionalProperties: {}
nullable: true
type: array
entities_allow_free_form:
@@ -4784,6 +4784,12 @@ components:
strategy:
nullable: true
type: string
update_mode:
enum:
- replace
- append
nullable: true
type: string
required:
- content
title: MemoryItem
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.4.22
// APIClient manages communication with the Hindsight HTTP API API v0.5.0
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -29,7 +29,7 @@ type BankTemplateConfig struct {
DispositionSkepticism NullableInt32 `json:"disposition_skepticism,omitempty"`
DispositionLiteralism NullableInt32 `json:"disposition_literalism,omitempty"`
DispositionEmpathy NullableInt32 `json:"disposition_empathy,omitempty"`
EntityLabels []string `json:"entity_labels,omitempty"`
EntityLabels []map[string]interface{} `json:"entity_labels,omitempty"`
EntitiesAllowFreeForm NullableBool `json:"entities_allow_free_form,omitempty"`
}
@@ -471,9 +471,9 @@ func (o *BankTemplateConfig) UnsetDispositionEmpathy() {
}
// GetEntityLabels returns the EntityLabels field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetEntityLabels() []string {
func (o *BankTemplateConfig) GetEntityLabels() []map[string]interface{} {
if o == nil {
var ret []string
var ret []map[string]interface{}
return ret
}
return o.EntityLabels
@@ -482,7 +482,7 @@ func (o *BankTemplateConfig) GetEntityLabels() []string {
// GetEntityLabelsOk returns a tuple with the EntityLabels field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetEntityLabelsOk() ([]string, bool) {
func (o *BankTemplateConfig) GetEntityLabelsOk() ([]map[string]interface{}, bool) {
if o == nil || IsNil(o.EntityLabels) {
return nil, false
}
@@ -498,8 +498,8 @@ func (o *BankTemplateConfig) HasEntityLabels() bool {
return false
}
// SetEntityLabels gets a reference to the given []string and assigns it to the EntityLabels field.
func (o *BankTemplateConfig) SetEntityLabels(v []string) {
// SetEntityLabels gets a reference to the given []map[string]interface{} and assigns it to the EntityLabels field.
func (o *BankTemplateConfig) SetEntityLabels(v []map[string]interface{}) {
o.EntityLabels = v
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.22
API version: 0.5.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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