feat(cli): cover every OpenAPI endpoint and request-body param (#968)
Wires the Rust CLI up to every endpoint exposed by the Hindsight OpenAPI
spec and adds CI enforcement so new endpoints or new request-body fields
cannot slip in without matching CLI coverage.
Endpoints
- New `hindsight webhook {list,create,update,delete,deliveries}` and
`hindsight audit {list,stats}` subcommands.
- `hindsight bank` gains `set-disposition`, `consolidation-recover`,
`export-template`, `import-template`, `template-schema`.
- `hindsight memory` gains `history` and per-memory `clear-observations`.
- `hindsight document update`, `hindsight operation retry` added.
- Brings CLI coverage from 46/62 to 62/62 operations.
Request-body parameters
- Expose missing flags that the CLI was silently hardcoding: directive
`--priority`; mental-model `--tags` / `--max-tokens` /
`--trigger-refresh-after-consolidation`; recall `--query-timestamp`;
reflect `--fact-types` / `--exclude-mental-models` /
`--exclude-mental-model-ids`; retain `--document-tags`.
CI enforcement
- New `cli-coverage-check` entry point in `hindsight-dev` parses
openapi.json and verifies that (a) every operationId is called from
hindsight-cli/src/ (the progenitor client method names match the
operationId), and (b) every request-body property is present in
main.rs as a clap field or `long = "..."` attribute.
- Intentional non-exposures live in `hindsight-cli/.openapi-coverage.toml`
under `[skip]` / `[fields.<op>]` with a reason each (38 documented
field skips for flattened structs, nested structs, or fields surfaced
via a different subcommand).
- New `check-cli-coverage` job in .github/workflows/test.yml, triggered
on cli/core/dev/ci path changes, runs the script on every PR.
- smoke-test.sh exercises the new webhook / audit / bank-template /
set-disposition / consolidation-recover commands.
This commit is contained in:
parent
fc941d5cae
commit
c05c491d77
16 changed files with 2990 additions and 287 deletions
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
|
|
@ -2510,6 +2510,40 @@ jobs:
|
||||||
cd hindsight-dev
|
cd hindsight-dev
|
||||||
uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json
|
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.
|
# 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,
|
# 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.
|
# so we create a commit status on the PR head SHA and post a comment.
|
||||||
|
|
@ -2553,6 +2587,7 @@ jobs:
|
||||||
- test-upgrade
|
- test-upgrade
|
||||||
- verify-generated-files
|
- verify-generated-files
|
||||||
- check-openapi-compatibility
|
- check-openapi-compatibility
|
||||||
|
- check-cli-coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
statuses: write
|
statuses: write
|
||||||
|
|
|
||||||
90
hindsight-cli/.openapi-coverage.toml
Normal file
90
hindsight-cli/.openapi-coverage.toml
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Hindsight CLI ↔ OpenAPI coverage manifest.
|
||||||
|
#
|
||||||
|
# The CI job `cli-coverage-check` (see hindsight-dev/hindsight_dev/cli_coverage_check.py)
|
||||||
|
# enforces both endpoint-level and parameter-level coverage:
|
||||||
|
#
|
||||||
|
# 1. Every operationId in hindsight-docs/static/openapi.json must be either
|
||||||
|
# called from hindsight-cli/src/**/*.rs (the progenitor-generated client
|
||||||
|
# methods are named identically to the operationId) or listed under
|
||||||
|
# [skip] below with a reason.
|
||||||
|
#
|
||||||
|
# 2. For each operation with a JSON request body, every top-level property
|
||||||
|
# of that body must be either present in hindsight-cli/src/main.rs as a
|
||||||
|
# clap command variant field (`field_name: <type>`) or a `long = "..."`
|
||||||
|
# attribute, OR listed under [fields.<operation_id>] below with a reason.
|
||||||
|
#
|
||||||
|
# Skip entries should explain *why* the field/operation is not exposed (e.g.
|
||||||
|
# flattened into several CLI flags, complex nested struct, available via a
|
||||||
|
# different subcommand).
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Operation-level skips
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[skip]
|
||||||
|
# (empty — every operation is currently wired)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-operation parameter skips
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[fields.add_bank_background]
|
||||||
|
update_disposition = "Exposed inverted as --no-update-disposition on `bank background`."
|
||||||
|
|
||||||
|
[fields.create_or_update_bank]
|
||||||
|
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank create`."
|
||||||
|
disposition_skepticism = "Covered by --skepticism; the flat form is an API alias."
|
||||||
|
disposition_literalism = "Covered by --literalism; the flat form is an API alias."
|
||||||
|
disposition_empathy = "Covered by --empathy; the flat form is an API alias."
|
||||||
|
background = "Set via the dedicated `bank background` subcommand."
|
||||||
|
reflect_mission = "Set via `bank set-config --reflect-mission`."
|
||||||
|
retain_mission = "Set via `bank set-config --retain-mission`."
|
||||||
|
retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`."
|
||||||
|
retain_custom_instructions = "Set via `bank set-config` (hierarchical config)."
|
||||||
|
retain_chunk_size = "Set via `bank set-config` (hierarchical config)."
|
||||||
|
enable_observations = "Set via `bank set-config` (hierarchical config)."
|
||||||
|
observations_mission = "Set via `bank set-config --observations-mission`."
|
||||||
|
|
||||||
|
[fields.update_bank]
|
||||||
|
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank update`."
|
||||||
|
disposition_skepticism = "Covered by --skepticism; the flat form is an API alias."
|
||||||
|
disposition_literalism = "Covered by --literalism; the flat form is an API alias."
|
||||||
|
disposition_empathy = "Covered by --empathy; the flat form is an API alias."
|
||||||
|
background = "Set via the dedicated `bank background` subcommand."
|
||||||
|
reflect_mission = "Set via `bank set-config --reflect-mission`."
|
||||||
|
retain_mission = "Set via `bank set-config --retain-mission`."
|
||||||
|
retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`."
|
||||||
|
retain_custom_instructions = "Set via `bank set-config` (hierarchical config)."
|
||||||
|
retain_chunk_size = "Set via `bank set-config` (hierarchical config)."
|
||||||
|
enable_observations = "Set via `bank set-config` (hierarchical config)."
|
||||||
|
observations_mission = "Set via `bank set-config --observations-mission`."
|
||||||
|
|
||||||
|
[fields.update_bank_disposition]
|
||||||
|
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank set-disposition`."
|
||||||
|
|
||||||
|
[fields.update_bank_config]
|
||||||
|
updates = "Flattened into per-setting flags (--llm-provider, --llm-model, etc) on `bank set-config`."
|
||||||
|
|
||||||
|
[fields.create_webhook]
|
||||||
|
http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed."
|
||||||
|
|
||||||
|
[fields.update_webhook]
|
||||||
|
http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed."
|
||||||
|
|
||||||
|
[fields.recall_memories]
|
||||||
|
types = "CLI exposes this as --fact-type (the schema property is named `types` but it holds fact types)."
|
||||||
|
include = "Flattened into --include-chunks / --chunk-max-tokens (facts are always included)."
|
||||||
|
tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases."
|
||||||
|
|
||||||
|
[fields.reflect]
|
||||||
|
include = "Flattened into --include-facts and related flags."
|
||||||
|
response_schema = "Exposed as --schema (path to a JSON schema file)."
|
||||||
|
tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases."
|
||||||
|
|
||||||
|
[fields.retain_memories]
|
||||||
|
items = "Constructed from the single positional content argument on `memory retain`."
|
||||||
|
|
||||||
|
[fields.create_mental_model]
|
||||||
|
trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model create` (other nested trigger fields like fact_types/tag_groups are not exposed yet)."
|
||||||
|
|
||||||
|
[fields.update_mental_model]
|
||||||
|
trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model update` (other nested trigger fields like fact_types/tag_groups are not exposed yet)."
|
||||||
|
|
@ -118,6 +118,41 @@ run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
|
||||||
# Test 15: List operations
|
# Test 15: List operations
|
||||||
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
|
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
|
# Test 16: Delete bank
|
||||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
//! to bridge from the CLI's synchronous code to the async API client.
|
//! to bridge from the CLI's synchronous code to the async API client.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hindsight_client::Client as AsyncClient;
|
|
||||||
pub use hindsight_client::types;
|
pub use hindsight_client::types;
|
||||||
|
use hindsight_client::Client as AsyncClient;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -76,8 +76,8 @@ impl ApiClient {
|
||||||
let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?);
|
let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?);
|
||||||
|
|
||||||
// Create HTTP client with 2-minute timeout and optional auth header
|
// Create HTTP client with 2-minute timeout and optional auth header
|
||||||
let mut client_builder = reqwest::Client::builder()
|
let mut client_builder =
|
||||||
.timeout(std::time::Duration::from_secs(120));
|
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
|
@ -92,7 +92,12 @@ impl ApiClient {
|
||||||
let http_client = client_builder.build()?;
|
let http_client = client_builder.build()?;
|
||||||
|
|
||||||
let client = AsyncClient::new_with_client(&base_url, http_client.clone());
|
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>> {
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.get_bank_profile(agent_id, None).await?;
|
let response = self.client.get_bank_profile(agent_id, None).await?;
|
||||||
Ok(response.into_inner())
|
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 {
|
self.runtime.block_on(async {
|
||||||
let request = types::CreateBankRequest {
|
let request = types::CreateBankRequest {
|
||||||
name: Some(name.to_string()),
|
name: Some(name.to_string()),
|
||||||
|
|
@ -129,25 +143,45 @@ impl ApiClient {
|
||||||
disposition: None,
|
disposition: None,
|
||||||
..Default::default()
|
..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())
|
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 {
|
self.runtime.block_on(async {
|
||||||
let request = types::AddBackgroundRequest {
|
let request = types::AddBackgroundRequest {
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
update_disposition,
|
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())
|
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 {
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.recall_memories(agent_id, None, request).await?;
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.reflect(agent_id, None, request).await?;
|
let response = self.client.reflect(agent_id, None, request).await?;
|
||||||
Ok(response.into_inner())
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.retain_memories(agent_id, None, request).await?;
|
let response = self.client.retain_memories(agent_id, None, request).await?;
|
||||||
let result = response.into_inner();
|
let result = response.into_inner();
|
||||||
|
|
@ -186,7 +231,10 @@ impl ApiClient {
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> Result<FileRetainResult> {
|
) -> Result<FileRetainResult> {
|
||||||
self.runtime.block_on(async {
|
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
|
let files_metadata: Vec<serde_json::Value> = files
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -210,8 +258,8 @@ impl ApiClient {
|
||||||
"files_metadata": files_metadata,
|
"files_metadata": files_metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut form = reqwest::multipart::Form::new()
|
let mut form =
|
||||||
.text("request", request_json.to_string());
|
reqwest::multipart::Form::new().text("request", request_json.to_string());
|
||||||
|
|
||||||
for (filename, content) in files {
|
for (filename, content) in files {
|
||||||
let part = reqwest::multipart::Part::bytes(content)
|
let part = reqwest::multipart::Part::bytes(content)
|
||||||
|
|
@ -239,10 +287,18 @@ impl ApiClient {
|
||||||
|
|
||||||
/// Poll an operation until it completes or fails.
|
/// Poll an operation until it completes or fails.
|
||||||
/// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error.
|
/// 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 {
|
self.runtime.block_on(async {
|
||||||
loop {
|
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();
|
let ops = response.into_inner();
|
||||||
|
|
||||||
// Find our operation
|
// Find our operation
|
||||||
|
|
@ -267,7 +323,10 @@ impl ApiClient {
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Unknown status, treat as failed
|
// Unknown status, treat as failed
|
||||||
return Ok((false, Some(format!("Unknown status: {}", operation.status))));
|
return Ok((
|
||||||
|
false,
|
||||||
|
Some(format!("Unknown status: {}", operation.status)),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -280,21 +339,43 @@ 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
|
// 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.")
|
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 {
|
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())
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.list_documents(
|
let response = self
|
||||||
|
.client
|
||||||
|
.list_documents(
|
||||||
agent_id,
|
agent_id,
|
||||||
limit.map(|l| l as i64),
|
limit.map(|l| l as i64),
|
||||||
offset.map(|o| o as i64),
|
offset.map(|o| o as i64),
|
||||||
|
|
@ -302,21 +383,38 @@ impl ApiClient {
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
Ok(response.into_inner())
|
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 {
|
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())
|
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 {
|
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();
|
let value = response.into_inner();
|
||||||
// Convert typed response to DeleteResponse
|
// Convert typed response to DeleteResponse
|
||||||
Ok(types::DeleteResponse {
|
Ok(types::DeleteResponse {
|
||||||
|
|
@ -329,7 +427,10 @@ impl ApiClient {
|
||||||
|
|
||||||
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
|
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
|
||||||
self.runtime.block_on(async {
|
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();
|
let value = response.into_inner();
|
||||||
// Convert to JSON Value first, then parse into our type
|
// Convert to JSON Value first, then parse into our type
|
||||||
let json_value = serde_json::to_value(&value)?;
|
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 {
|
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();
|
let value = response.into_inner();
|
||||||
// Convert typed response to DeleteResponse
|
// Convert typed response to DeleteResponse
|
||||||
Ok(types::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 {
|
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())
|
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 {
|
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())
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.get_entity(bank_id, entity_id, None).await?;
|
let response = self.client.get_entity(bank_id, entity_id, None).await?;
|
||||||
Ok(response.into_inner())
|
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 {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -394,7 +536,12 @@ impl ApiClient {
|
||||||
impl ApiClient {
|
impl ApiClient {
|
||||||
// --- Memory Methods ---
|
// --- 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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.get_memory(bank_id, memory_id, None).await?;
|
let response = self.client.get_memory(bank_id, memory_id, None).await?;
|
||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
|
|
@ -410,7 +557,10 @@ impl ApiClient {
|
||||||
_verbose: bool,
|
_verbose: bool,
|
||||||
) -> Result<types::BankProfileResponse> {
|
) -> Result<types::BankProfileResponse> {
|
||||||
self.runtime.block_on(async {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -454,7 +604,10 @@ impl ApiClient {
|
||||||
_verbose: bool,
|
_verbose: bool,
|
||||||
) -> Result<types::GraphDataResponse> {
|
) -> Result<types::GraphDataResponse> {
|
||||||
self.runtime.block_on(async {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -478,9 +631,15 @@ impl ApiClient {
|
||||||
) -> Result<types::BankConfigResponse> {
|
) -> Result<types::BankConfigResponse> {
|
||||||
self.runtime.block_on(async {
|
self.runtime.block_on(async {
|
||||||
// Convert HashMap to serde_json::Map
|
// Convert HashMap to serde_json::Map
|
||||||
let updates_map: serde_json::Map<String, serde_json::Value> = updates.into_iter().collect();
|
let updates_map: serde_json::Map<String, serde_json::Value> =
|
||||||
let request = types::BankConfigUpdate { updates: updates_map };
|
updates.into_iter().collect();
|
||||||
let response = self.client.update_bank_config(bank_id, None, &request).await?;
|
let request = types::BankConfigUpdate {
|
||||||
|
updates: updates_map,
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.update_bank_config(bank_id, None, &request)
|
||||||
|
.await?;
|
||||||
Ok(response.into_inner())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -507,7 +666,10 @@ impl ApiClient {
|
||||||
_verbose: bool,
|
_verbose: bool,
|
||||||
) -> Result<types::ListTagsResponse> {
|
) -> Result<types::ListTagsResponse> {
|
||||||
self.runtime.block_on(async {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -523,9 +685,17 @@ impl ApiClient {
|
||||||
|
|
||||||
// --- Operation Methods ---
|
// --- 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 {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -548,16 +718,31 @@ impl ApiClient {
|
||||||
|
|
||||||
// --- Mental Model Methods ---
|
// --- 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 {
|
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())
|
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 {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -569,7 +754,10 @@ impl ApiClient {
|
||||||
_verbose: bool,
|
_verbose: bool,
|
||||||
) -> Result<types::CreateMentalModelResponse> {
|
) -> Result<types::CreateMentalModelResponse> {
|
||||||
self.runtime.block_on(async {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -582,44 +770,86 @@ impl ApiClient {
|
||||||
_verbose: bool,
|
_verbose: bool,
|
||||||
) -> Result<types::MentalModelResponse> {
|
) -> Result<types::MentalModelResponse> {
|
||||||
self.runtime.block_on(async {
|
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())
|
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 {
|
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())
|
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 {
|
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())
|
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 {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Directive Methods ---
|
// --- 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 {
|
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())
|
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 {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -644,28 +874,47 @@ impl ApiClient {
|
||||||
_verbose: bool,
|
_verbose: bool,
|
||||||
) -> Result<types::DirectiveResponse> {
|
) -> Result<types::DirectiveResponse> {
|
||||||
self.runtime.block_on(async {
|
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())
|
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 {
|
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())
|
Ok(response.into_inner())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Consolidation Methods ---
|
// --- 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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.trigger_consolidation(bank_id, None).await?;
|
let response = self.client.trigger_consolidation(bank_id, None).await?;
|
||||||
Ok(response.into_inner())
|
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 {
|
self.runtime.block_on(async {
|
||||||
let response = self.client.clear_observations(bank_id, None).await?;
|
let response = self.client.clear_observations(bank_id, None).await?;
|
||||||
Ok(response.into_inner())
|
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
|
// Re-export types from the generated client for use in commands
|
||||||
pub use types::{
|
pub use types::{
|
||||||
BankProfileResponse,
|
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
|
||||||
MemoryItem,
|
ReflectResponse, RetainRequest,
|
||||||
RecallRequest,
|
|
||||||
RecallResponse,
|
|
||||||
RecallResult,
|
|
||||||
ReflectRequest,
|
|
||||||
ReflectResponse,
|
|
||||||
RetainRequest,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
118
hindsight-cli/src/commands/audit.rs
Normal file
118
hindsight-cli/src/commands/audit.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
//! Audit log commands.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::api::ApiClient;
|
||||||
|
use crate::output::{self, OutputFormat};
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
/// List audit log entries for a bank
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn list(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
action: Option<String>,
|
||||||
|
transport: Option<String>,
|
||||||
|
start_date: Option<String>,
|
||||||
|
end_date: Option<String>,
|
||||||
|
limit: Option<u64>,
|
||||||
|
offset: Option<u64>,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Fetching audit logs..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.list_audit_logs(
|
||||||
|
bank_id,
|
||||||
|
action.as_deref(),
|
||||||
|
transport.as_deref(),
|
||||||
|
start_date.as_deref(),
|
||||||
|
end_date.as_deref(),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
verbose,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_section_header(&format!("Audit logs: {}", bank_id));
|
||||||
|
println!(
|
||||||
|
" {} {} ({} total)",
|
||||||
|
ui::dim("Showing:"),
|
||||||
|
result.items.len(),
|
||||||
|
result.total
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
if result.items.is_empty() {
|
||||||
|
println!(" {}", ui::dim("No audit log entries."));
|
||||||
|
} else {
|
||||||
|
for entry in &result.items {
|
||||||
|
let started = entry.started_at.as_deref().unwrap_or("-");
|
||||||
|
let duration = entry
|
||||||
|
.duration_ms
|
||||||
|
.map(|d| format!("{}ms", d))
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
println!(
|
||||||
|
" {} {} [{}] {}",
|
||||||
|
ui::dim(started),
|
||||||
|
ui::gradient_start(&entry.action),
|
||||||
|
entry.transport,
|
||||||
|
duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output::print_output(&result, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get audit log statistics for a bank
|
||||||
|
pub fn stats(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
action: Option<String>,
|
||||||
|
period: Option<String>,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Fetching audit log stats..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.audit_log_stats(bank_id, action.as_deref(), period.as_deref(), verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_section_header(&format!("Audit stats: {}", bank_id));
|
||||||
|
println!(" {} {}", ui::dim("Period:"), result.period);
|
||||||
|
println!(" {} {}", ui::dim("Start:"), result.start);
|
||||||
|
println!(" {} {}", ui::dim("Bucket:"), result.trunc);
|
||||||
|
println!();
|
||||||
|
if result.buckets.is_empty() {
|
||||||
|
println!(" {}", ui::dim("No activity in this period."));
|
||||||
|
} else {
|
||||||
|
for bucket in &result.buckets {
|
||||||
|
let json = serde_json::to_value(bucket)?;
|
||||||
|
println!(" {}", json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output::print_output(&result, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use crate::api::ApiClient;
|
use crate::api::ApiClient;
|
||||||
use crate::output::{self, OutputFormat};
|
use crate::output::{self, OutputFormat};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
|
||||||
pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> Result<()> {
|
pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> Result<()> {
|
||||||
let spinner = if output_format == OutputFormat::Pretty {
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
|
@ -32,11 +32,16 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
Some(ui::create_spinner("Fetching disposition..."))
|
Some(ui::create_spinner("Fetching disposition..."))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -58,11 +63,16 @@ pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_form
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
Some(ui::create_spinner("Fetching statistics..."))
|
Some(ui::create_spinner("Fetching statistics..."))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -80,9 +90,21 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||||
if output_format == OutputFormat::Pretty {
|
if output_format == OutputFormat::Pretty {
|
||||||
ui::print_section_header(&format!("Statistics: {}", bank_id));
|
ui::print_section_header(&format!("Statistics: {}", bank_id));
|
||||||
|
|
||||||
println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string()));
|
println!(
|
||||||
println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string()));
|
" {} {}",
|
||||||
println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string()));
|
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!();
|
||||||
|
|
||||||
println!("{}", ui::gradient_text("─── Memory Units by Type ───"));
|
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);
|
fact_types.sort_by_key(|(k, _)| *k);
|
||||||
for (i, (fact_type, count)) in fact_types.iter().enumerate() {
|
for (i, (fact_type, count)) in fact_types.iter().enumerate() {
|
||||||
let t = i as f32 / fact_types.len().max(1) as f32;
|
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!();
|
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);
|
link_types.sort_by_key(|(k, _)| *k);
|
||||||
for (i, (link_type, count)) in link_types.iter().enumerate() {
|
for (i, (link_type, count)) in link_types.iter().enumerate() {
|
||||||
let t = i as f32 / link_types.len().max(1) as f32;
|
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!();
|
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);
|
fact_type_links.sort_by_key(|(k, _)| *k);
|
||||||
for (i, (fact_type, count)) in fact_type_links.iter().enumerate() {
|
for (i, (fact_type, count)) in fact_type_links.iter().enumerate() {
|
||||||
let t = i as f32 / fact_type_links.len().max(1) as f32;
|
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!();
|
println!();
|
||||||
|
|
||||||
|
|
@ -141,11 +175,17 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
Some(ui::create_spinner("Updating bank name..."))
|
Some(ui::create_spinner("Updating bank name..."))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -167,7 +207,7 @@ pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool,
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +217,7 @@ pub fn update_background(
|
||||||
content: &str,
|
content: &str,
|
||||||
no_update_disposition: bool,
|
no_update_disposition: bool,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let current_profile = if !no_update_disposition {
|
let current_profile = if !no_update_disposition {
|
||||||
client.get_profile(bank_id, verbose).ok()
|
client.get_profile(bank_id, verbose).ok()
|
||||||
|
|
@ -204,9 +244,10 @@ pub fn update_background(
|
||||||
println!("\n{}", profile.mission);
|
println!("\n{}", profile.mission);
|
||||||
|
|
||||||
if !no_update_disposition {
|
if !no_update_disposition {
|
||||||
if let (Some(old_p), Some(new_p)) =
|
if let (Some(old_p), Some(new_p)) = (
|
||||||
(current_profile.as_ref().map(|p| p.disposition.clone()), &profile.disposition)
|
current_profile.as_ref().map(|p| p.disposition.clone()),
|
||||||
{
|
&profile.disposition,
|
||||||
|
) {
|
||||||
println!("\nDisposition changes:");
|
println!("\nDisposition changes:");
|
||||||
println!(" Skepticism: {} → {}", old_p.skepticism, new_p.skepticism);
|
println!(" Skepticism: {} → {}", old_p.skepticism, new_p.skepticism);
|
||||||
println!(" Literalism: {} → {}", old_p.literalism, new_p.literalism);
|
println!(" Literalism: {} → {}", old_p.literalism, new_p.literalism);
|
||||||
|
|
@ -218,7 +259,7 @@ pub fn update_background(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,7 +370,12 @@ pub fn update(
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> 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)");
|
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 {
|
if output_format == OutputFormat::Pretty {
|
||||||
ui::print_section_header(&format!("Memory Graph: {}", bank_id));
|
ui::print_section_header(&format!("Memory Graph: {}", bank_id));
|
||||||
|
|
||||||
println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string()));
|
println!(
|
||||||
println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string()));
|
" {} {}",
|
||||||
|
ui::dim("Nodes:"),
|
||||||
|
ui::gradient_start(&result.nodes.len().to_string())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
ui::dim("Edges:"),
|
||||||
|
ui::gradient_end(&result.edges.len().to_string())
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
// Show sample of nodes
|
// Show sample of nodes
|
||||||
if !result.nodes.is_empty() {
|
if !result.nodes.is_empty() {
|
||||||
println!("{}", ui::gradient_text("─── Sample Nodes ───"));
|
println!("{}", ui::gradient_text("─── Sample Nodes ───"));
|
||||||
for node in result.nodes.iter().take(5) {
|
for node in result.nodes.iter().take(5) {
|
||||||
let fact_type = node.get("type")
|
let fact_type = node
|
||||||
.and_then(|v| v.as_str())
|
.get("type")
|
||||||
.unwrap_or("unknown");
|
|
||||||
let id = node.get("id")
|
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
|
let id = node.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
println!(" {} [{}]", ui::dim(id), fact_type);
|
println!(" {} [{}]", ui::dim(id), fact_type);
|
||||||
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
|
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
|
||||||
let preview: String = text.chars().take(60).collect();
|
let preview: String = text.chars().take(60).collect();
|
||||||
|
|
@ -429,12 +482,18 @@ pub fn graph(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.nodes.len() > 5 {
|
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!();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
output::print_output(&result, output_format)?;
|
output::print_output(&result, output_format)?;
|
||||||
}
|
}
|
||||||
|
|
@ -449,7 +508,7 @@ pub fn delete(
|
||||||
bank_id: &str,
|
bank_id: &str,
|
||||||
yes: bool,
|
yes: bool,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Confirmation prompt unless -y flag is used
|
// Confirmation prompt unless -y flag is used
|
||||||
if !yes && output_format == OutputFormat::Pretty {
|
if !yes && output_format == OutputFormat::Pretty {
|
||||||
|
|
@ -494,7 +553,7 @@ pub fn delete(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -527,7 +586,11 @@ pub fn consolidate(
|
||||||
ui::print_success("Consolidation triggered");
|
ui::print_success("Consolidation triggered");
|
||||||
println!(" {} {}", ui::dim("Operation ID:"), operation_id);
|
println!(" {} {}", ui::dim("Operation ID:"), operation_id);
|
||||||
if result.deduplicated {
|
if result.deduplicated {
|
||||||
println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task");
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
ui::dim("Note:"),
|
||||||
|
"Reusing existing pending consolidation task"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
output::print_output(&result, output_format)?;
|
output::print_output(&result, output_format)?;
|
||||||
|
|
@ -544,7 +607,13 @@ pub fn consolidate(
|
||||||
// Poll for completion
|
// Poll for completion
|
||||||
if output_format == OutputFormat::Pretty {
|
if output_format == OutputFormat::Pretty {
|
||||||
println!();
|
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();
|
let start = std::time::Instant::now();
|
||||||
|
|
@ -561,7 +630,10 @@ pub fn consolidate(
|
||||||
match op.map(|o| o.status.as_str()) {
|
match op.map(|o| o.status.as_str()) {
|
||||||
Some("completed") => {
|
Some("completed") => {
|
||||||
if output_format == OutputFormat::Pretty {
|
if output_format == OutputFormat::Pretty {
|
||||||
ui::print_success(&format!("Consolidation completed ({}s)", elapsed));
|
ui::print_success(&format!(
|
||||||
|
"Consolidation completed ({}s)",
|
||||||
|
elapsed
|
||||||
|
));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -571,7 +643,10 @@ pub fn consolidate(
|
||||||
.map(|s| s.as_str())
|
.map(|s| s.as_str())
|
||||||
.unwrap_or("Unknown error");
|
.unwrap_or("Unknown error");
|
||||||
if output_format == OutputFormat::Pretty {
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -582,7 +657,10 @@ pub fn consolidate(
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if output_format == OutputFormat::Pretty {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -732,37 +810,67 @@ pub fn set_config(
|
||||||
let mut updates: HashMap<String, serde_json::Value> = HashMap::new();
|
let mut updates: HashMap<String, serde_json::Value> = HashMap::new();
|
||||||
|
|
||||||
if let Some(provider) = llm_provider {
|
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 {
|
if let Some(model) = llm_model {
|
||||||
updates.insert("llm_model".to_string(), serde_json::Value::String(model));
|
updates.insert("llm_model".to_string(), serde_json::Value::String(model));
|
||||||
}
|
}
|
||||||
if let Some(api_key) = llm_api_key {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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() {
|
if updates.is_empty() {
|
||||||
|
|
@ -832,7 +940,10 @@ pub fn reset_config(
|
||||||
match response {
|
match response {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if output_format == OutputFormat::Pretty {
|
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 {
|
} else {
|
||||||
output::print_output(&result, output_format)?;
|
output::print_output(&result, output_format)?;
|
||||||
}
|
}
|
||||||
|
|
@ -841,3 +952,188 @@ pub fn reset_config(
|
||||||
Err(e) => Err(e),
|
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
|
/// Create a new directive
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn create(
|
pub fn create(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
bank_id: &str,
|
bank_id: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
priority: i64,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -117,7 +119,7 @@ pub fn create(
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
priority: 0,
|
priority,
|
||||||
tags: vec![],
|
tags: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -143,6 +145,7 @@ pub fn create(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a directive
|
/// Update a directive
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn update(
|
pub fn update(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
bank_id: &str,
|
bank_id: &str,
|
||||||
|
|
@ -150,11 +153,14 @@ pub fn update(
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
is_active: Option<bool>,
|
is_active: Option<bool>,
|
||||||
|
priority: Option<i64>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if name.is_none() && content.is_none() && is_active.is_none() {
|
if name.is_none() && content.is_none() && is_active.is_none() && priority.is_none() {
|
||||||
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
|
anyhow::bail!(
|
||||||
|
"At least one of --name, --content, --is-active, or --priority must be provided"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let spinner = if output_format == OutputFormat::Pretty {
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
|
@ -167,7 +173,7 @@ pub fn update(
|
||||||
name,
|
name,
|
||||||
content,
|
content,
|
||||||
is_active,
|
is_active,
|
||||||
priority: None,
|
priority,
|
||||||
tags: None,
|
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::api::ApiClient;
|
||||||
use crate::output::{self, OutputFormat};
|
use crate::output::{self, OutputFormat};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{Duration as ChronoDuration, NaiveDate, Utc};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
pub fn list(
|
pub fn list(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
|
|
@ -26,7 +26,13 @@ pub fn list(
|
||||||
None
|
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 {
|
if let Some(mut sp) = spinner {
|
||||||
sp.finish();
|
sp.finish();
|
||||||
|
|
@ -35,13 +41,25 @@ pub fn list(
|
||||||
match response {
|
match response {
|
||||||
Ok(docs_response) => {
|
Ok(docs_response) => {
|
||||||
if output_format == OutputFormat::Pretty {
|
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 {
|
for doc in &docs_response.items {
|
||||||
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
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 created = doc
|
||||||
let updated = doc.get("updated_at").and_then(|v| v.as_str()).unwrap_or("unknown");
|
.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 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!("\n Document ID: {}", id);
|
||||||
println!(" Created: {}", created);
|
println!(" Created: {}", created);
|
||||||
|
|
@ -54,7 +72,7 @@ pub fn list(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,9 +105,7 @@ fn list_with_date(
|
||||||
let mut filtered_count = 0;
|
let mut filtered_count = 0;
|
||||||
|
|
||||||
for doc in all_docs {
|
for doc in all_docs {
|
||||||
let created_at = doc.get("created_at")
|
let created_at = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
// Parse the date part (YYYY-MM-DD) from created_at
|
// Parse the date part (YYYY-MM-DD) from created_at
|
||||||
let doc_date = created_at.split('T').next().unwrap_or("");
|
let doc_date = created_at.split('T').next().unwrap_or("");
|
||||||
|
|
@ -126,7 +142,10 @@ fn list_with_date(
|
||||||
println!(" {} ({} documents)", date_str, docs.len());
|
println!(" {} ({} documents)", date_str, docs.len());
|
||||||
for doc in docs {
|
for doc in docs {
|
||||||
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
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!(" - {} ({} memories)", id, mem_count);
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
|
|
@ -224,7 +243,7 @@ pub fn get(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,6 +279,45 @@ pub fn delete(
|
||||||
}
|
}
|
||||||
Ok(())
|
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 std::path::PathBuf;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::api::{ApiClient, RecallRequest, ReflectRequest, MemoryItem, RetainRequest};
|
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::output::{self, OutputFormat};
|
use crate::output::{self, OutputFormat};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
||||||
// Import types from generated client
|
// 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::Deserialize;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
|
|
@ -45,7 +48,12 @@ fn parse_budget(budget: &str) -> Budget {
|
||||||
|
|
||||||
// Helper function to parse tags_match string to TagsMatch enum
|
// Helper function to parse tags_match string to TagsMatch enum
|
||||||
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
|
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,
|
"all" => TagsMatch::All,
|
||||||
"any_strict" => TagsMatch::AnyStrict,
|
"any_strict" => TagsMatch::AnyStrict,
|
||||||
"all_strict" => TagsMatch::AllStrict,
|
"all_strict" => TagsMatch::AllStrict,
|
||||||
|
|
@ -86,13 +94,19 @@ pub fn list(
|
||||||
match response {
|
match response {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if output_format == OutputFormat::Pretty {
|
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() {
|
if result.items.is_empty() {
|
||||||
println!(" {}", ui::dim("No memories found."));
|
println!(" {}", ui::dim("No memories found."));
|
||||||
} else {
|
} else {
|
||||||
for item in &result.items {
|
for item in &result.items {
|
||||||
let fact_type = item.get("type")
|
let fact_type = item
|
||||||
|
.get("type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("unknown");
|
.unwrap_or("unknown");
|
||||||
let type_t = match fact_type {
|
let type_t = match fact_type {
|
||||||
|
|
@ -102,9 +116,7 @@ pub fn list(
|
||||||
_ => 0.5,
|
_ => 0.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = item.get("id")
|
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("unknown");
|
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {} {}",
|
" {} {}",
|
||||||
|
|
@ -172,7 +184,11 @@ pub fn get(
|
||||||
|
|
||||||
ui::print_section_header(&format!("Memory: {}", memory_id));
|
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);
|
println!(" {} {}", ui::dim("ID:"), result.id);
|
||||||
|
|
||||||
if let Some(doc_id) = &result.document_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 {
|
fn is_supported_file(path: &std::path::Path) -> bool {
|
||||||
const SUPPORTED_EXTENSIONS: &[&str] = &[
|
const SUPPORTED_EXTENSIONS: &[&str] = &[
|
||||||
// Documents
|
// Documents
|
||||||
"pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls",
|
"pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", // Images (OCR)
|
||||||
// Images (OCR)
|
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", // Web / markup
|
||||||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff",
|
"html", "htm", // Text / data
|
||||||
// Web / markup
|
|
||||||
"html", "htm",
|
|
||||||
// Text / data
|
|
||||||
"txt", "md", "csv", "json", "yaml", "yml", "toml", "xml", "rst", "adoc", "log",
|
"txt", "md", "csv", "json", "yaml", "yml", "toml", "xml", "rst", "adoc", "log",
|
||||||
// Audio (transcription)
|
// Audio (transcription)
|
||||||
"mp3", "wav", "ogg", "flac",
|
"mp3", "wav", "ogg", "flac",
|
||||||
|
|
@ -250,6 +263,7 @@ fn is_supported_file(path: &std::path::Path) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn recall(
|
pub fn recall(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
|
|
@ -262,6 +276,7 @@ pub fn recall(
|
||||||
chunk_max_tokens: i64,
|
chunk_max_tokens: i64,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
tags_match: Option<String>,
|
tags_match: Option<String>,
|
||||||
|
query_timestamp: Option<String>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -286,11 +301,15 @@ pub fn recall(
|
||||||
|
|
||||||
let request = RecallRequest {
|
let request = RecallRequest {
|
||||||
query,
|
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)),
|
budget: Some(parse_budget(&budget)),
|
||||||
max_tokens,
|
max_tokens,
|
||||||
trace,
|
trace,
|
||||||
query_timestamp: None,
|
query_timestamp,
|
||||||
include,
|
include,
|
||||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||||
tags_match: parse_tags_match(&tags_match),
|
tags_match: parse_tags_match(&tags_match),
|
||||||
|
|
@ -312,10 +331,11 @@ pub fn recall(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn reflect(
|
pub fn reflect(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
|
|
@ -327,6 +347,9 @@ pub fn reflect(
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
tags_match: Option<String>,
|
tags_match: Option<String>,
|
||||||
include_facts: bool,
|
include_facts: bool,
|
||||||
|
fact_types: Option<Vec<String>>,
|
||||||
|
exclude_mental_models: bool,
|
||||||
|
exclude_mental_model_ids: Option<Vec<String>>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -340,7 +363,8 @@ pub fn reflect(
|
||||||
let response_schema = if let Some(path) = schema_path {
|
let response_schema = if let Some(path) = schema_path {
|
||||||
let schema_content = fs::read_to_string(&path)
|
let schema_content = fs::read_to_string(&path)
|
||||||
.with_context(|| format!("Failed to read schema file: {}", path.display()))?;
|
.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)
|
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()))?;
|
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
|
||||||
Some(schema)
|
Some(schema)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -356,6 +380,21 @@ pub fn reflect(
|
||||||
None
|
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 {
|
let request = ReflectRequest {
|
||||||
query,
|
query,
|
||||||
budget: Some(parse_budget(&budget)),
|
budget: Some(parse_budget(&budget)),
|
||||||
|
|
@ -366,9 +405,9 @@ pub fn reflect(
|
||||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||||
tags_match: parse_tags_match(&tags_match),
|
tags_match: parse_tags_match(&tags_match),
|
||||||
tag_groups: None,
|
tag_groups: None,
|
||||||
fact_types: None,
|
fact_types: mapped_fact_types,
|
||||||
exclude_mental_models: false,
|
exclude_mental_models,
|
||||||
exclude_mental_model_ids: None,
|
exclude_mental_model_ids,
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client.reflect(agent_id, &request, verbose);
|
let response = client.reflect(agent_id, &request, verbose);
|
||||||
|
|
@ -386,10 +425,11 @@ pub fn reflect(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn retain(
|
pub fn retain(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
|
|
@ -397,6 +437,7 @@ pub fn retain(
|
||||||
doc_id: Option<String>,
|
doc_id: Option<String>,
|
||||||
context: Option<String>,
|
context: Option<String>,
|
||||||
r#async: bool,
|
r#async: bool,
|
||||||
|
document_tags: Option<Vec<String>>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -424,7 +465,7 @@ pub fn retain(
|
||||||
let request = RetainRequest {
|
let request = RetainRequest {
|
||||||
items: vec![item],
|
items: vec![item],
|
||||||
async_: r#async,
|
async_: r#async,
|
||||||
document_tags: None,
|
document_tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client.retain(agent_id, &request, r#async, verbose);
|
let response = client.retain(agent_id, &request, r#async, verbose);
|
||||||
|
|
@ -451,7 +492,7 @@ pub fn retain(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -618,7 +659,7 @@ pub fn delete(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,10 +729,85 @@ pub fn clear(
|
||||||
}
|
}
|
||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -700,8 +816,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_supported_file_text_extensions() {
|
fn test_is_supported_file_text_extensions() {
|
||||||
let supported = [
|
let supported = [
|
||||||
"file.txt", "file.md", "file.json", "file.yaml", "file.yml",
|
"file.txt",
|
||||||
"file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc",
|
"file.md",
|
||||||
|
"file.json",
|
||||||
|
"file.yaml",
|
||||||
|
"file.yml",
|
||||||
|
"file.toml",
|
||||||
|
"file.xml",
|
||||||
|
"file.csv",
|
||||||
|
"file.log",
|
||||||
|
"file.rst",
|
||||||
|
"file.adoc",
|
||||||
];
|
];
|
||||||
for filename in supported {
|
for filename in supported {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -715,9 +840,16 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_supported_file_binary_extensions() {
|
fn test_is_supported_file_binary_extensions() {
|
||||||
let supported = [
|
let supported = [
|
||||||
"file.pdf", "file.docx", "file.pptx", "file.xlsx",
|
"file.pdf",
|
||||||
"file.png", "file.jpg", "file.jpeg", "file.gif",
|
"file.docx",
|
||||||
"file.mp3", "file.wav",
|
"file.pptx",
|
||||||
|
"file.xlsx",
|
||||||
|
"file.png",
|
||||||
|
"file.jpg",
|
||||||
|
"file.jpeg",
|
||||||
|
"file.gif",
|
||||||
|
"file.mp3",
|
||||||
|
"file.wav",
|
||||||
];
|
];
|
||||||
for filename in supported {
|
for filename in supported {
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -739,9 +871,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_supported_file_unsupported_extensions() {
|
fn test_is_supported_file_unsupported_extensions() {
|
||||||
let unsupported = [
|
let unsupported = ["file.exe", "file.bin", "file.zip", "file.tar", "file.gz"];
|
||||||
"file.exe", "file.bin", "file.zip", "file.tar", "file.gz",
|
|
||||||
];
|
|
||||||
for filename in unsupported {
|
for filename in unsupported {
|
||||||
assert!(
|
assert!(
|
||||||
!is_supported_file(Path::new(filename)),
|
!is_supported_file(Path::new(filename)),
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,16 @@ pub fn get(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new mental model
|
/// Create a new mental model
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn create(
|
pub fn create(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
bank_id: &str,
|
bank_id: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
source_query: &str,
|
source_query: &str,
|
||||||
id: Option<&str>,
|
id: Option<&str>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
max_tokens: i64,
|
||||||
|
trigger_refresh_after_consolidation: bool,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -110,13 +114,28 @@ pub fn create(
|
||||||
None
|
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 {
|
let request = types::CreateMentalModelRequest {
|
||||||
id: id.map(|s| s.to_string()),
|
id: id.map(|s| s.to_string()),
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
source_query: source_query.to_string(),
|
source_query: source_query.to_string(),
|
||||||
max_tokens: 2048,
|
max_tokens,
|
||||||
tags: vec![],
|
tags,
|
||||||
trigger: None,
|
trigger,
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client.create_mental_model(bank_id, &request, verbose);
|
let response = client.create_mental_model(bank_id, &request, verbose);
|
||||||
|
|
@ -139,16 +158,29 @@ pub fn create(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a mental model
|
/// Update a mental model
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn update(
|
pub fn update(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
bank_id: &str,
|
bank_id: &str,
|
||||||
mental_model_id: &str,
|
mental_model_id: &str,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
source_query: Option<String>,
|
||||||
|
max_tokens: Option<i64>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
trigger_refresh_after_consolidation: Option<bool>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if name.is_none() {
|
if name.is_none()
|
||||||
anyhow::bail!("--name must be provided");
|
&& 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 {
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
|
@ -157,12 +189,23 @@ pub fn update(
|
||||||
None
|
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 {
|
let request = types::UpdateMentalModelRequest {
|
||||||
name,
|
name,
|
||||||
source_query: None,
|
source_query,
|
||||||
max_tokens: None,
|
max_tokens,
|
||||||
tags: None,
|
tags,
|
||||||
trigger: None,
|
trigger,
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client.update_mental_model(bank_id, mental_model_id, &request, verbose);
|
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 bank;
|
||||||
pub mod chunk;
|
pub mod chunk;
|
||||||
pub mod directive;
|
pub mod directive;
|
||||||
|
|
@ -6,6 +7,7 @@ pub mod entity;
|
||||||
pub mod explore;
|
pub mod explore;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod operation;
|
|
||||||
pub mod mental_model;
|
pub mod mental_model;
|
||||||
|
pub mod operation;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
pub mod webhook;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use anyhow::Result;
|
|
||||||
use crate::api::ApiClient;
|
use crate::api::ApiClient;
|
||||||
use crate::output::{self, OutputFormat};
|
use crate::output::{self, OutputFormat};
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
pub fn list(
|
pub fn list(
|
||||||
client: &ApiClient,
|
client: &ApiClient,
|
||||||
|
|
@ -27,7 +27,10 @@ pub fn list(
|
||||||
if ops_response.operations.is_empty() {
|
if ops_response.operations.is_empty() {
|
||||||
ui::print_info("No operations found");
|
ui::print_info("No operations found");
|
||||||
} else {
|
} 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 {
|
for op in &ops_response.operations {
|
||||||
println!("\n Operation ID: {}", op.id);
|
println!("\n Operation ID: {}", op.id);
|
||||||
println!(" Type: {}", op.task_type);
|
println!(" Type: {}", op.task_type);
|
||||||
|
|
@ -43,7 +46,7 @@ pub fn list(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +131,40 @@ pub fn cancel(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e)
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retry a failed async operation
|
||||||
|
pub fn retry(
|
||||||
|
client: &ApiClient,
|
||||||
|
agent_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Retrying operation..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.retry_operation(agent_id, operation_id, verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_success(&format!("Operation '{}' retried", operation_id));
|
||||||
|
let json = serde_json::to_value(&result)?;
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
output::print_output(&result, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
249
hindsight-cli/src/commands/webhook.rs
Normal file
249
hindsight-cli/src/commands/webhook.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
//! Webhook commands for managing event delivery hooks.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::api::ApiClient;
|
||||||
|
use crate::output::{self, OutputFormat};
|
||||||
|
use crate::ui;
|
||||||
|
|
||||||
|
use hindsight_client::types;
|
||||||
|
|
||||||
|
/// List webhooks for a bank
|
||||||
|
pub fn list(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Fetching webhooks..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.list_webhooks(bank_id, verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_section_header(&format!("Webhooks: {}", bank_id));
|
||||||
|
if result.items.is_empty() {
|
||||||
|
println!(" {}", ui::dim("No webhooks configured."));
|
||||||
|
} else {
|
||||||
|
for wh in &result.items {
|
||||||
|
let status = if wh.enabled {
|
||||||
|
ui::gradient_start("enabled")
|
||||||
|
} else {
|
||||||
|
ui::dim("disabled")
|
||||||
|
};
|
||||||
|
println!(" {} [{}] {}", ui::gradient_start(&wh.id), status, wh.url);
|
||||||
|
if !wh.event_types.is_empty() {
|
||||||
|
println!(" events: {}", wh.event_types.join(", "));
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output::print_output(&result, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new webhook
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn create(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
url: &str,
|
||||||
|
event_types: Vec<String>,
|
||||||
|
enabled: bool,
|
||||||
|
secret: Option<String>,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Creating webhook..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let effective_events = if event_types.is_empty() {
|
||||||
|
vec!["consolidation.completed".to_string()]
|
||||||
|
} else {
|
||||||
|
event_types
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = types::CreateWebhookRequest {
|
||||||
|
enabled,
|
||||||
|
event_types: effective_events,
|
||||||
|
http_config: None,
|
||||||
|
secret,
|
||||||
|
url: url.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.create_webhook(bank_id, &request, verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let wh = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_success(&format!("Webhook '{}' created", wh.id));
|
||||||
|
println!(" URL: {}", wh.url);
|
||||||
|
println!(" Events: {}", wh.event_types.join(", "));
|
||||||
|
} else {
|
||||||
|
output::print_output(&wh, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a webhook
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn update(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
webhook_id: &str,
|
||||||
|
url: Option<String>,
|
||||||
|
event_types: Option<Vec<String>>,
|
||||||
|
enabled: Option<bool>,
|
||||||
|
secret: Option<String>,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
if url.is_none() && event_types.is_none() && enabled.is_none() && secret.is_none() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"At least one of --url, --event-types, --enabled, or --secret must be provided"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Updating webhook..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = types::UpdateWebhookRequest {
|
||||||
|
enabled,
|
||||||
|
event_types,
|
||||||
|
http_config: None,
|
||||||
|
secret,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.update_webhook(bank_id, webhook_id, &request, verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let wh = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_success(&format!("Webhook '{}' updated", wh.id));
|
||||||
|
} else {
|
||||||
|
output::print_output(&wh, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a webhook
|
||||||
|
pub fn delete(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
webhook_id: &str,
|
||||||
|
yes: bool,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !yes && output_format == OutputFormat::Pretty {
|
||||||
|
let message = format!(
|
||||||
|
"Are you sure you want to delete webhook '{}'? This cannot be undone.",
|
||||||
|
webhook_id
|
||||||
|
);
|
||||||
|
if !ui::prompt_confirmation(&message)? {
|
||||||
|
ui::print_info("Operation cancelled");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Deleting webhook..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client.delete_webhook(bank_id, webhook_id, verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
if result.success {
|
||||||
|
ui::print_success(&format!("Webhook '{}' deleted", webhook_id));
|
||||||
|
} else {
|
||||||
|
ui::print_error("Failed to delete webhook");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output::print_output(&result, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List recent delivery attempts for a webhook
|
||||||
|
pub fn deliveries(
|
||||||
|
client: &ApiClient,
|
||||||
|
bank_id: &str,
|
||||||
|
webhook_id: &str,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
verbose: bool,
|
||||||
|
output_format: OutputFormat,
|
||||||
|
) -> Result<()> {
|
||||||
|
let spinner = if output_format == OutputFormat::Pretty {
|
||||||
|
Some(ui::create_spinner("Fetching deliveries..."))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let response =
|
||||||
|
client.list_webhook_deliveries(bank_id, webhook_id, cursor.as_deref(), limit, verbose);
|
||||||
|
|
||||||
|
if let Some(mut sp) = spinner {
|
||||||
|
sp.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response?;
|
||||||
|
if output_format == OutputFormat::Pretty {
|
||||||
|
ui::print_section_header(&format!("Deliveries for {}", webhook_id));
|
||||||
|
if result.items.is_empty() {
|
||||||
|
println!(" {}", ui::dim("No delivery attempts recorded."));
|
||||||
|
} else {
|
||||||
|
for d in &result.items {
|
||||||
|
println!(
|
||||||
|
" {} [{}] {} — attempts: {}",
|
||||||
|
ui::gradient_start(&d.id),
|
||||||
|
d.event_type,
|
||||||
|
d.last_response_status
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
d.attempts
|
||||||
|
);
|
||||||
|
if let Some(err) = &d.last_error {
|
||||||
|
println!(" {} {}", ui::dim("error:"), err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cursor) = &result.next_cursor {
|
||||||
|
println!();
|
||||||
|
println!(" {} {}", ui::dim("next cursor:"), cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output::print_output(&result, output_format)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
304
hindsight-dev/hindsight_dev/cli_coverage_check.py
Normal file
304
hindsight-dev/hindsight_dev/cli_coverage_check.py
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Check that the Hindsight CLI covers every operation AND every request-body
|
||||||
|
parameter in the OpenAPI spec.
|
||||||
|
|
||||||
|
Endpoint-level check
|
||||||
|
--------------------
|
||||||
|
For each ``operationId`` in ``hindsight-docs/static/openapi.json`` we require
|
||||||
|
that one of the following is true:
|
||||||
|
1. A call of the form ``.<operation_id>(`` appears somewhere in
|
||||||
|
``hindsight-cli/src/**/*.rs``. The progenitor-generated Rust client
|
||||||
|
methods are named identically to the OpenAPI ``operationId``, so this is
|
||||||
|
a strong signal the CLI wires the endpoint.
|
||||||
|
2. The ``operationId`` is listed in ``hindsight-cli/.openapi-coverage.toml``
|
||||||
|
under ``[skip]`` with a reason.
|
||||||
|
|
||||||
|
Parameter-level check
|
||||||
|
---------------------
|
||||||
|
For each operation whose request body schema has named properties, we require
|
||||||
|
that every property is one of:
|
||||||
|
1. Present in ``hindsight-cli/src/main.rs`` as a clap command variant field
|
||||||
|
(``field_name: <type>``) or as a ``long = "..."`` clap attribute. main.rs
|
||||||
|
is where the user-facing CLI args live, so absence there means the user
|
||||||
|
has no way to set that field.
|
||||||
|
2. Listed in ``.openapi-coverage.toml`` under
|
||||||
|
``[fields.<operation_id>]`` with a reason explaining why it is not
|
||||||
|
exposed (for example, flattened into several CLI flags, or a complex
|
||||||
|
nested struct).
|
||||||
|
|
||||||
|
Stale manifest entries (skips for things that no longer need skipping) also
|
||||||
|
fail the check.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd hindsight-dev
|
||||||
|
uv run cli-coverage-check
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
|
||||||
|
|
||||||
|
# OpenAPI request body fields sometimes collide with Rust reserved keywords;
|
||||||
|
# the progenitor client serde-renames them (e.g. `async` → `async_`). When we
|
||||||
|
# look up these fields in main.rs, we also accept the aliased name.
|
||||||
|
RUST_KEYWORD_ALIASES: dict[str, set[str]] = {
|
||||||
|
"async": {"async_", "async_mode", "r#async"},
|
||||||
|
"type": {"type_", "r#type"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_root() -> Path:
|
||||||
|
here = Path(__file__).resolve().parent
|
||||||
|
for parent in (here, *here.parents):
|
||||||
|
if (parent / "hindsight-cli").is_dir() and (parent / "hindsight-docs").is_dir():
|
||||||
|
return parent
|
||||||
|
raise RuntimeError(f"Could not locate repo root from {here}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_operations(spec_path: Path) -> tuple[set[str], dict[str, list[str]]]:
|
||||||
|
"""Return (operation_ids, op_id -> list of request-body property names).
|
||||||
|
|
||||||
|
Operations without a request body are omitted from the property map.
|
||||||
|
"""
|
||||||
|
spec = json.loads(spec_path.read_text())
|
||||||
|
op_ids: set[str] = set()
|
||||||
|
op_props: dict[str, list[str]] = {}
|
||||||
|
schemas = spec.get("components", {}).get("schemas", {})
|
||||||
|
|
||||||
|
for path_item in spec.get("paths", {}).values():
|
||||||
|
for method, op in path_item.items():
|
||||||
|
if method.lower() not in HTTP_METHODS or not isinstance(op, dict):
|
||||||
|
continue
|
||||||
|
op_id = op.get("operationId")
|
||||||
|
if not op_id:
|
||||||
|
continue
|
||||||
|
op_ids.add(op_id)
|
||||||
|
|
||||||
|
body = op.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema")
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
if "$ref" in body:
|
||||||
|
schema_name = body["$ref"].split("/")[-1]
|
||||||
|
schema = schemas.get(schema_name, {})
|
||||||
|
else:
|
||||||
|
schema = body
|
||||||
|
props = list((schema.get("properties") or {}).keys())
|
||||||
|
if props:
|
||||||
|
op_props[op_id] = props
|
||||||
|
|
||||||
|
return op_ids, op_props
|
||||||
|
|
||||||
|
|
||||||
|
def load_manifest(path: Path) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
|
||||||
|
"""Return (operation-level skips, per-operation field skips)."""
|
||||||
|
if not path.exists():
|
||||||
|
return {}, {}
|
||||||
|
data = tomllib.loads(path.read_text())
|
||||||
|
|
||||||
|
skip_raw = data.get("skip", {}) or {}
|
||||||
|
if not isinstance(skip_raw, dict):
|
||||||
|
raise ValueError(f"{path}: [skip] must be a table")
|
||||||
|
op_skips: dict[str, str] = {}
|
||||||
|
for op_id, reason in skip_raw.items():
|
||||||
|
if not isinstance(reason, str) or not reason.strip():
|
||||||
|
raise ValueError(f"{path}: skip.{op_id} must be a non-empty string reason")
|
||||||
|
op_skips[op_id] = reason.strip()
|
||||||
|
|
||||||
|
fields_raw = data.get("fields", {}) or {}
|
||||||
|
if not isinstance(fields_raw, dict):
|
||||||
|
raise ValueError(f"{path}: [fields] must be a table")
|
||||||
|
field_skips: dict[str, dict[str, str]] = {}
|
||||||
|
for op_id, table in fields_raw.items():
|
||||||
|
if not isinstance(table, dict):
|
||||||
|
raise ValueError(f"{path}: [fields.{op_id}] must be a table")
|
||||||
|
per_op: dict[str, str] = {}
|
||||||
|
for field, reason in table.items():
|
||||||
|
if not isinstance(reason, str) or not reason.strip():
|
||||||
|
raise ValueError(f"{path}: fields.{op_id}.{field} must be a non-empty string reason")
|
||||||
|
per_op[field] = reason.strip()
|
||||||
|
field_skips[op_id] = per_op
|
||||||
|
|
||||||
|
return op_skips, field_skips
|
||||||
|
|
||||||
|
|
||||||
|
def load_rust_source(cli_src: Path) -> str:
|
||||||
|
chunks: list[str] = []
|
||||||
|
for rs in cli_src.rglob("*.rs"):
|
||||||
|
try:
|
||||||
|
chunks.append(rs.read_text())
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return "\n".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def find_implemented_ops(all_src: str, op_ids: set[str]) -> set[str]:
|
||||||
|
"""Subset of op_ids that appear as ``.<op_id>(`` in any Rust file."""
|
||||||
|
implemented: set[str] = set()
|
||||||
|
for op_id in op_ids:
|
||||||
|
if re.search(r"\.\s*" + re.escape(op_id) + r"\s*\(", all_src):
|
||||||
|
implemented.add(op_id)
|
||||||
|
return implemented
|
||||||
|
|
||||||
|
|
||||||
|
def field_in_main_rs(prop: str, main_src: str) -> bool:
|
||||||
|
"""Is `prop` exposed as a CLI arg in main.rs?
|
||||||
|
|
||||||
|
We look for either a struct variant field declaration
|
||||||
|
(``field_name: <type>``) or an explicit clap ``long = "..."`` attribute
|
||||||
|
matching the property name (snake_case or kebab-case).
|
||||||
|
"""
|
||||||
|
kebab = prop.replace("_", "-")
|
||||||
|
patterns = [
|
||||||
|
# Struct variant field declaration. We require a type after the colon
|
||||||
|
# to distinguish from URL path tokens like `/v1/default/banks/...`.
|
||||||
|
r"\b" + re.escape(prop) + r"\s*:\s*(?:Option<|Vec<|bool|i\d+|u\d+|f\d+|String|PathBuf|Path)",
|
||||||
|
# Explicit clap long attribute (either spelling).
|
||||||
|
rf'long\s*=\s*"{re.escape(prop)}"',
|
||||||
|
rf'long\s*=\s*"{re.escape(kebab)}"',
|
||||||
|
]
|
||||||
|
return any(re.search(p, main_src) for p in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def field_covered(prop: str, main_src: str) -> bool:
|
||||||
|
if field_in_main_rs(prop, main_src):
|
||||||
|
return True
|
||||||
|
for alias in RUST_KEYWORD_ALIASES.get(prop, set()):
|
||||||
|
if field_in_main_rs(alias, main_src):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
root = get_repo_root()
|
||||||
|
spec_path = root / "hindsight-docs" / "static" / "openapi.json"
|
||||||
|
manifest_path = root / "hindsight-cli" / ".openapi-coverage.toml"
|
||||||
|
cli_src_dir = root / "hindsight-cli" / "src"
|
||||||
|
main_rs_path = cli_src_dir / "main.rs"
|
||||||
|
|
||||||
|
if not spec_path.exists():
|
||||||
|
print(f"ERROR: OpenAPI spec not found at {spec_path}", file=sys.stderr)
|
||||||
|
print(" Run ./scripts/generate-openapi.sh first.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not cli_src_dir.is_dir():
|
||||||
|
print(f"ERROR: CLI source dir not found at {cli_src_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not main_rs_path.exists():
|
||||||
|
print(f"ERROR: main.rs not found at {main_rs_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
spec_ops, op_props = load_operations(spec_path)
|
||||||
|
op_skips, field_skips = load_manifest(manifest_path)
|
||||||
|
all_src = load_rust_source(cli_src_dir)
|
||||||
|
main_src = main_rs_path.read_text()
|
||||||
|
implemented = find_implemented_ops(all_src, spec_ops)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# ----- endpoint-level -----
|
||||||
|
unmapped_ops = sorted(spec_ops - implemented - op_skips.keys())
|
||||||
|
for op_id in unmapped_ops:
|
||||||
|
errors.append(
|
||||||
|
f"MISSING OP {op_id}: not called from hindsight-cli/src/ and not in .openapi-coverage.toml [skip]"
|
||||||
|
)
|
||||||
|
|
||||||
|
stale_op_skips = sorted(op_skips.keys() - spec_ops)
|
||||||
|
for op_id in stale_op_skips:
|
||||||
|
errors.append(
|
||||||
|
f"STALE OP {op_id}: listed in [skip] but not present in "
|
||||||
|
f"openapi.json. Remove it from .openapi-coverage.toml."
|
||||||
|
)
|
||||||
|
|
||||||
|
redundant_op_skips = sorted(op_skips.keys() & implemented)
|
||||||
|
for op_id in redundant_op_skips:
|
||||||
|
errors.append(
|
||||||
|
f"REDUNDANT OP {op_id}: listed in [skip] but is now called from "
|
||||||
|
f"the CLI. Remove it from .openapi-coverage.toml."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- parameter-level -----
|
||||||
|
total_props = 0
|
||||||
|
skipped_props = 0
|
||||||
|
covered_props = 0
|
||||||
|
for op_id, props in op_props.items():
|
||||||
|
# If the whole operation is intentionally skipped, its params don't
|
||||||
|
# need to be covered either.
|
||||||
|
if op_id in op_skips:
|
||||||
|
continue
|
||||||
|
per_op_skips = field_skips.get(op_id, {})
|
||||||
|
for prop in props:
|
||||||
|
total_props += 1
|
||||||
|
if prop in per_op_skips:
|
||||||
|
skipped_props += 1
|
||||||
|
continue
|
||||||
|
if field_covered(prop, main_src):
|
||||||
|
covered_props += 1
|
||||||
|
continue
|
||||||
|
errors.append(
|
||||||
|
f"MISSING PARAM {op_id}.{prop}: request body field not exposed "
|
||||||
|
f"as a CLI arg in main.rs and not listed in "
|
||||||
|
f"[fields.{op_id}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stale field skips (unknown operations or unknown fields)
|
||||||
|
for op_id, per_op in field_skips.items():
|
||||||
|
if op_id not in op_props:
|
||||||
|
for field in per_op:
|
||||||
|
errors.append(
|
||||||
|
f"STALE PARAM {op_id}.{field}: [fields.{op_id}] references "
|
||||||
|
f"an operation with no request body in the spec"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
known = set(op_props[op_id])
|
||||||
|
for field in per_op:
|
||||||
|
if field not in known:
|
||||||
|
errors.append(
|
||||||
|
f"STALE PARAM {op_id}.{field}: field not present in the "
|
||||||
|
f"{op_id} request schema. Remove from [fields.{op_id}]."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: we intentionally do NOT flag [fields.<op>] entries as "redundant"
|
||||||
|
# when the field name also appears in main.rs. Field names like
|
||||||
|
# `retain_mission` can be a struct variant field under one command (e.g.
|
||||||
|
# `bank set-config`) while being legitimately skipped for another
|
||||||
|
# (e.g. `bank create`, which the skip points to). A global redundancy
|
||||||
|
# check can't tell those apart without a per-operation → command-variant
|
||||||
|
# mapping, so we keep only the stricter checks above.
|
||||||
|
|
||||||
|
total = len(spec_ops)
|
||||||
|
impl_count = len(implemented & spec_ops)
|
||||||
|
op_skip_count = len(op_skips.keys() & spec_ops)
|
||||||
|
|
||||||
|
print("Hindsight CLI OpenAPI coverage check")
|
||||||
|
print(f" Spec: {spec_path.relative_to(root)}")
|
||||||
|
print(f" Manifest: {manifest_path.relative_to(root)}")
|
||||||
|
print(f" Operations: {total}")
|
||||||
|
print(f" implemented: {impl_count}")
|
||||||
|
print(f" skipped: {op_skip_count}")
|
||||||
|
print(f" Request params: {total_props}")
|
||||||
|
print(f" covered: {covered_props}")
|
||||||
|
print(f" skipped: {skipped_props}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"FAILED: {len(errors)} issue(s):")
|
||||||
|
for e in errors:
|
||||||
|
print(f" {e}")
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Fix by either exposing the endpoint/field as a CLI arg, or adding\n"
|
||||||
|
"an entry to hindsight-cli/.openapi-coverage.toml with a reason."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"OK: all {total} operations and {total_props} request params covered.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -36,6 +36,7 @@ generate-changelog = "hindsight_dev.generate_changelog:main"
|
||||||
sync-cookbook = "hindsight_dev.sync_cookbook:main"
|
sync-cookbook = "hindsight_dev.sync_cookbook:main"
|
||||||
generate-llms-full = "hindsight_dev.generate_llms_full:main"
|
generate-llms-full = "hindsight_dev.generate_llms_full:main"
|
||||||
check-openapi-compatibility = "hindsight_dev.check_openapi_compatibility:main"
|
check-openapi-compatibility = "hindsight_dev.check_openapi_compatibility:main"
|
||||||
|
cli-coverage-check = "hindsight_dev.cli_coverage_check:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue