Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99fb8ee4fd | ||
|
|
576016f5dc | ||
|
|
b3995d1430 | ||
|
|
f519fc4fd0 | ||
|
|
72fd3d59db | ||
|
|
5a61ac50e9 | ||
|
|
1f1716bdb0 | ||
|
|
61a8014f9d | ||
|
|
c5091d29cd | ||
|
|
e82bc56580 | ||
|
|
fa0e63b088 | ||
|
|
27cb7e43e0 | ||
|
|
9e23e83abf | ||
|
|
bdf93f0660 | ||
|
|
b0e8ac0f4d | ||
|
|
f74b577e02 | ||
|
|
3c633e5e16 | ||
|
|
cf0537ba7e | ||
|
|
e5944b63e7 | ||
|
|
37348c859e | ||
|
|
cece2c903c | ||
|
|
d7c73f4342 | ||
|
|
3b9d2db091 | ||
|
|
9790d904e0 | ||
|
|
2463efd0f2 | ||
|
|
6674ee4706 | ||
|
|
57f154454d | ||
|
|
4028dd91f8 | ||
|
|
0e81d1a25e | ||
|
|
8a2388a48f | ||
|
|
48185a4bee | ||
|
|
7e23f8e149 | ||
|
|
f659bb17c4 | ||
|
|
f31f82627c | ||
|
|
e1c6220f0e |
@@ -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:
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tgz
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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}`];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -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,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",
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.22"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
+42
@@ -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)")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user