Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7519ecac95 | ||
|
|
ab0f8cec33 | ||
|
|
19676d69a7 |
@@ -139,7 +139,7 @@ jobs:
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-openclawd-integration:
|
||||
release-moltbot-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
@@ -153,15 +153,15 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: |
|
||||
set +e
|
||||
OUTPUT=$(npm publish --access public 2>&1)
|
||||
@@ -178,14 +178,14 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openclawd-integration
|
||||
path: hindsight-integrations/openclawd/*.tgz
|
||||
name: moltbot-integration
|
||||
path: hindsight-integrations/moltbot/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-control-plane:
|
||||
@@ -415,7 +415,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-openclawd-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-moltbot-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -438,11 +438,11 @@ jobs:
|
||||
name: typescript-client
|
||||
path: ./artifacts/typescript-client
|
||||
|
||||
- name: Download OpenClawd Integration
|
||||
- name: Download Moltbot Integration
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: openclawd-integration
|
||||
path: ./artifacts/openclawd-integration
|
||||
name: moltbot-integration
|
||||
path: ./artifacts/moltbot-integration
|
||||
|
||||
- name: Download Control Plane
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -485,8 +485,8 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# OpenClawd Integration
|
||||
cp artifacts/openclawd-integration/*.tgz release-assets/ || true
|
||||
# Moltbot Integration
|
||||
cp artifacts/moltbot-integration/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Build TypeScript client
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
build-openclawd-integration:
|
||||
build-moltbot-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -94,15 +94,15 @@ jobs:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/openclawd
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm run build
|
||||
|
||||
build-control-plane:
|
||||
|
||||
@@ -1404,9 +1404,10 @@ def create_app(
|
||||
worker_id=worker_id,
|
||||
executor=memory.execute_task,
|
||||
poll_interval_ms=config.worker_poll_interval_ms,
|
||||
batch_size=config.worker_batch_size,
|
||||
max_retries=config.worker_max_retries,
|
||||
tenant_extension=getattr(memory, "_tenant_extension", None),
|
||||
max_slots=config.worker_max_slots,
|
||||
consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
)
|
||||
poller_task = asyncio.create_task(poller.run())
|
||||
logging.info(f"Worker poller started (worker_id={worker_id})")
|
||||
|
||||
@@ -143,8 +143,9 @@ ENV_WORKER_ENABLED = "HINDSIGHT_API_WORKER_ENABLED"
|
||||
ENV_WORKER_ID = "HINDSIGHT_API_WORKER_ID"
|
||||
ENV_WORKER_POLL_INTERVAL_MS = "HINDSIGHT_API_WORKER_POLL_INTERVAL_MS"
|
||||
ENV_WORKER_MAX_RETRIES = "HINDSIGHT_API_WORKER_MAX_RETRIES"
|
||||
ENV_WORKER_BATCH_SIZE = "HINDSIGHT_API_WORKER_BATCH_SIZE"
|
||||
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
|
||||
ENV_WORKER_MAX_SLOTS = "HINDSIGHT_API_WORKER_MAX_SLOTS"
|
||||
ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLOTS"
|
||||
|
||||
# Reflect agent settings
|
||||
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
@@ -229,8 +230,9 @@ DEFAULT_WORKER_ENABLED = True # API runs worker by default (standalone mode)
|
||||
DEFAULT_WORKER_ID = None # Will use hostname if not specified
|
||||
DEFAULT_WORKER_POLL_INTERVAL_MS = 500 # Poll database every 500ms
|
||||
DEFAULT_WORKER_MAX_RETRIES = 3 # Max retries before marking task failed
|
||||
DEFAULT_WORKER_BATCH_SIZE = 10 # Tasks to claim per poll cycle
|
||||
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
|
||||
DEFAULT_WORKER_MAX_SLOTS = 10 # Total concurrent tasks per worker
|
||||
DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks per worker
|
||||
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
@@ -419,8 +421,9 @@ class HindsightConfig:
|
||||
worker_id: str | None
|
||||
worker_poll_interval_ms: int
|
||||
worker_max_retries: int
|
||||
worker_batch_size: int
|
||||
worker_http_port: int
|
||||
worker_max_slots: int
|
||||
worker_consolidation_max_slots: int
|
||||
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
@@ -582,8 +585,11 @@ class HindsightConfig:
|
||||
worker_id=os.getenv(ENV_WORKER_ID) or DEFAULT_WORKER_ID,
|
||||
worker_poll_interval_ms=int(os.getenv(ENV_WORKER_POLL_INTERVAL_MS, str(DEFAULT_WORKER_POLL_INTERVAL_MS))),
|
||||
worker_max_retries=int(os.getenv(ENV_WORKER_MAX_RETRIES, str(DEFAULT_WORKER_MAX_RETRIES))),
|
||||
worker_batch_size=int(os.getenv(ENV_WORKER_BATCH_SIZE, str(DEFAULT_WORKER_BATCH_SIZE))),
|
||||
worker_http_port=int(os.getenv(ENV_WORKER_HTTP_PORT, str(DEFAULT_WORKER_HTTP_PORT))),
|
||||
worker_max_slots=int(os.getenv(ENV_WORKER_MAX_SLOTS, str(DEFAULT_WORKER_MAX_SLOTS))),
|
||||
worker_consolidation_max_slots=int(
|
||||
os.getenv(ENV_WORKER_CONSOLIDATION_MAX_SLOTS, str(DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS))
|
||||
),
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
|
||||
)
|
||||
|
||||
@@ -253,8 +253,9 @@ def main():
|
||||
worker_id=config.worker_id,
|
||||
worker_poll_interval_ms=config.worker_poll_interval_ms,
|
||||
worker_max_retries=config.worker_max_retries,
|
||||
worker_batch_size=config.worker_batch_size,
|
||||
worker_http_port=config.worker_http_port,
|
||||
worker_max_slots=config.worker_max_slots,
|
||||
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
)
|
||||
|
||||
@@ -124,12 +124,6 @@ def main():
|
||||
default=config.worker_poll_interval_ms,
|
||||
help=f"Poll interval in milliseconds (default: {config.worker_poll_interval_ms}, env: HINDSIGHT_API_WORKER_POLL_INTERVAL_MS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=config.worker_batch_size,
|
||||
help=f"Tasks to claim per poll (default: {config.worker_batch_size}, env: HINDSIGHT_API_WORKER_BATCH_SIZE)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-retries",
|
||||
type=int,
|
||||
@@ -168,8 +162,9 @@ def main():
|
||||
|
||||
print(f"Starting Hindsight Worker: {args.worker_id}")
|
||||
print(f" Poll interval: {args.poll_interval}ms")
|
||||
print(f" Batch size: {args.batch_size}")
|
||||
print(f" Max retries: {args.max_retries}")
|
||||
print(f" Max slots: {config.worker_max_slots}")
|
||||
print(f" Consolidation max slots: {config.worker_consolidation_max_slots}")
|
||||
print(f" HTTP server: {args.http_host}:{args.http_port}")
|
||||
print()
|
||||
|
||||
@@ -213,9 +208,10 @@ def main():
|
||||
worker_id=args.worker_id,
|
||||
executor=memory.execute_task,
|
||||
poll_interval_ms=args.poll_interval,
|
||||
batch_size=args.batch_size,
|
||||
max_retries=args.max_retries,
|
||||
tenant_extension=tenant_extension,
|
||||
max_slots=config.worker_max_slots,
|
||||
consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
)
|
||||
|
||||
# Create the HTTP app for metrics/health
|
||||
|
||||
@@ -57,10 +57,11 @@ class WorkerPoller:
|
||||
worker_id: str,
|
||||
executor: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
poll_interval_ms: int = 500,
|
||||
batch_size: int = 10,
|
||||
max_retries: int = 3,
|
||||
schema: str | None = None,
|
||||
tenant_extension: "TenantExtension | None" = None,
|
||||
max_slots: int = 10,
|
||||
consolidation_max_slots: int = 2,
|
||||
):
|
||||
"""
|
||||
Initialize the worker poller.
|
||||
@@ -70,28 +71,32 @@ class WorkerPoller:
|
||||
worker_id: Unique identifier for this worker
|
||||
executor: Async function to execute tasks (typically MemoryEngine.execute_task)
|
||||
poll_interval_ms: Interval between polls when no tasks found (milliseconds)
|
||||
batch_size: Maximum number of tasks to claim per poll cycle
|
||||
max_retries: Maximum retry attempts before marking task as failed
|
||||
schema: Database schema for single-tenant support (ignored if tenant_extension is set)
|
||||
tenant_extension: Extension for dynamic multi-tenant discovery. If set, list_tenants()
|
||||
is called on each poll cycle to discover schemas dynamically.
|
||||
max_slots: Maximum concurrent tasks per worker
|
||||
consolidation_max_slots: Maximum concurrent consolidation tasks per worker
|
||||
"""
|
||||
self._pool = pool
|
||||
self._worker_id = worker_id
|
||||
self._executor = executor
|
||||
self._poll_interval_ms = poll_interval_ms
|
||||
self._batch_size = batch_size
|
||||
self._max_retries = max_retries
|
||||
self._schema = schema
|
||||
self._tenant_extension = tenant_extension
|
||||
self._max_slots = max_slots
|
||||
self._consolidation_max_slots = consolidation_max_slots
|
||||
self._shutdown = asyncio.Event()
|
||||
self._current_tasks: set[asyncio.Task] = set()
|
||||
self._in_flight_count = 0
|
||||
self._in_flight_lock = asyncio.Lock()
|
||||
self._last_progress_log = 0.0
|
||||
self._tasks_completed_since_log = 0
|
||||
# Track active tasks locally: operation_id -> (op_type, bank_id, schema)
|
||||
self._active_tasks: dict[str, tuple[str, str, str | None]] = {}
|
||||
# Track active tasks locally: operation_id -> (op_type, bank_id, schema, asyncio.Task)
|
||||
self._active_tasks: dict[str, tuple[str, str, str | None, asyncio.Task]] = {}
|
||||
# Track in-flight tasks by operation type
|
||||
self._in_flight_by_type: dict[str, int] = {}
|
||||
|
||||
async def _get_schemas(self) -> list[str | None]:
|
||||
"""Get list of schemas to poll. Returns [None] for public schema."""
|
||||
@@ -102,67 +107,114 @@ class WorkerPoller:
|
||||
# Single schema mode
|
||||
return [self._schema]
|
||||
|
||||
async def _get_available_slots(self) -> tuple[int, int]:
|
||||
"""
|
||||
Calculate available slots for claiming tasks.
|
||||
|
||||
Returns:
|
||||
(total_available, consolidation_available) tuple
|
||||
"""
|
||||
async with self._in_flight_lock:
|
||||
total_in_flight = self._in_flight_count
|
||||
consolidation_in_flight = self._in_flight_by_type.get("consolidation", 0)
|
||||
|
||||
total_available = max(0, self._max_slots - total_in_flight)
|
||||
consolidation_available = max(0, self._consolidation_max_slots - consolidation_in_flight)
|
||||
|
||||
return total_available, consolidation_available
|
||||
|
||||
async def wait_for_active_tasks(self, timeout: float = 10.0) -> bool:
|
||||
"""
|
||||
Wait for all active background tasks to complete (test helper).
|
||||
|
||||
This is a test-only utility that allows tests to synchronize with
|
||||
fire-and-forget background tasks without using sleep().
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds
|
||||
|
||||
Returns:
|
||||
True if all tasks completed, False if timeout was reached
|
||||
"""
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while True:
|
||||
async with self._in_flight_lock:
|
||||
if self._in_flight_count == 0:
|
||||
return True
|
||||
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
if elapsed >= timeout:
|
||||
return False
|
||||
|
||||
# Short sleep to avoid busy-waiting
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
async def claim_batch(self) -> list[ClaimedTask]:
|
||||
"""
|
||||
Claim up to batch_size pending tasks atomically across all tenant schemas.
|
||||
Claim pending tasks atomically across all tenant schemas,
|
||||
respecting slot limits (total and consolidation).
|
||||
|
||||
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
|
||||
|
||||
For consolidation tasks specifically, skips pending tasks if there's already
|
||||
a processing consolidation for the same bank (to avoid duplicate work).
|
||||
|
||||
If tenant_extension is configured, dynamically discovers schemas on each call.
|
||||
|
||||
Returns:
|
||||
List of ClaimedTask objects containing operation_id, task_dict, and schema
|
||||
"""
|
||||
# Calculate available slots
|
||||
total_available, consolidation_available = await self._get_available_slots()
|
||||
|
||||
if total_available <= 0:
|
||||
return []
|
||||
|
||||
schemas = await self._get_schemas()
|
||||
all_tasks: list[ClaimedTask] = []
|
||||
remaining_batch = self._batch_size
|
||||
remaining_total = total_available
|
||||
remaining_consolidation = consolidation_available
|
||||
|
||||
for schema in schemas:
|
||||
if remaining_batch <= 0:
|
||||
if remaining_total <= 0:
|
||||
break
|
||||
|
||||
tasks = await self._claim_batch_for_schema(schema, remaining_batch)
|
||||
tasks = await self._claim_batch_for_schema(schema, remaining_total, remaining_consolidation)
|
||||
|
||||
# Update remaining slots based on what was claimed
|
||||
for task in tasks:
|
||||
op_type = task.task_dict.get("operation_type", "unknown")
|
||||
if op_type == "consolidation":
|
||||
remaining_consolidation -= 1
|
||||
|
||||
all_tasks.extend(tasks)
|
||||
remaining_batch -= len(tasks)
|
||||
remaining_total -= len(tasks)
|
||||
|
||||
return all_tasks
|
||||
|
||||
async def _claim_batch_for_schema(self, schema: str | None, limit: int) -> list[ClaimedTask]:
|
||||
"""Claim tasks from a specific schema."""
|
||||
async def _claim_batch_for_schema(
|
||||
self, schema: str | None, limit: int, consolidation_limit: int
|
||||
) -> list[ClaimedTask]:
|
||||
"""Claim tasks from a specific schema respecting slot limits."""
|
||||
try:
|
||||
return await self._claim_batch_for_schema_inner(schema, limit)
|
||||
return await self._claim_batch_for_schema_inner(schema, limit, consolidation_limit)
|
||||
except Exception as e:
|
||||
logger.warning(f"Worker {self._worker_id} failed to claim tasks for schema {schema or 'public'}: {e}")
|
||||
return []
|
||||
|
||||
async def _claim_batch_for_schema_inner(self, schema: str | None, limit: int) -> list[ClaimedTask]:
|
||||
"""Inner implementation for claiming tasks from a specific schema."""
|
||||
async def _claim_batch_for_schema_inner(
|
||||
self, schema: str | None, limit: int, consolidation_limit: int
|
||||
) -> list[ClaimedTask]:
|
||||
"""Inner implementation for claiming tasks from a specific schema with slot limits."""
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Select and lock pending tasks
|
||||
# For consolidation: skip if same bank already has one processing
|
||||
rows = await conn.fetch(
|
||||
# Strategy: Claim non-consolidation tasks first, then consolidation up to limit
|
||||
|
||||
# 1. Claim non-consolidation tasks (up to limit)
|
||||
non_consolidation_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT operation_id, task_payload
|
||||
FROM {table} AS pending
|
||||
WHERE status = 'pending' AND task_payload IS NOT NULL
|
||||
AND (
|
||||
-- Non-consolidation tasks: always claimable
|
||||
operation_type != 'consolidation'
|
||||
OR
|
||||
-- Consolidation: only if no other consolidation processing for same bank
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM {table} AS processing
|
||||
WHERE processing.bank_id = pending.bank_id
|
||||
AND processing.operation_type = 'consolidation'
|
||||
AND processing.status = 'processing'
|
||||
)
|
||||
)
|
||||
FROM {table}
|
||||
WHERE status = 'pending'
|
||||
AND task_payload IS NOT NULL
|
||||
AND operation_type != 'consolidation'
|
||||
ORDER BY created_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
@@ -170,11 +222,39 @@ class WorkerPoller:
|
||||
limit,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
claimed_count = len(non_consolidation_rows)
|
||||
remaining_limit = limit - claimed_count
|
||||
|
||||
# 2. Claim consolidation tasks (up to consolidation_limit and remaining_limit)
|
||||
consolidation_rows = []
|
||||
if consolidation_limit > 0 and remaining_limit > 0:
|
||||
consolidation_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT operation_id, task_payload
|
||||
FROM {table} AS pending
|
||||
WHERE status = 'pending'
|
||||
AND task_payload IS NOT NULL
|
||||
AND operation_type = 'consolidation'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM {table} AS processing
|
||||
WHERE processing.bank_id = pending.bank_id
|
||||
AND processing.operation_type = 'consolidation'
|
||||
AND processing.status = 'processing'
|
||||
)
|
||||
ORDER BY created_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
min(consolidation_limit, remaining_limit),
|
||||
)
|
||||
|
||||
all_rows = non_consolidation_rows + consolidation_rows
|
||||
|
||||
if not all_rows:
|
||||
return []
|
||||
|
||||
# Claim the tasks by updating status and worker_id
|
||||
operation_ids = [row["operation_id"] for row in rows]
|
||||
operation_ids = [row["operation_id"] for row in all_rows]
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
@@ -192,7 +272,7 @@ class WorkerPoller:
|
||||
task_dict=json.loads(row["task_payload"]),
|
||||
schema=schema,
|
||||
)
|
||||
for row in rows
|
||||
for row in all_rows
|
||||
]
|
||||
|
||||
async def _mark_completed(self, operation_id: str, schema: str | None):
|
||||
@@ -258,18 +338,43 @@ class WorkerPoller:
|
||||
logger.warning(f"Task {operation_id} failed, will retry (attempt {retry_count + 1}/{self._max_retries})")
|
||||
|
||||
async def execute_task(self, task: ClaimedTask):
|
||||
"""Execute a single task and update its status."""
|
||||
"""Execute a single task as a background job (fire-and-forget)."""
|
||||
task_type = task.task_dict.get("type", "unknown")
|
||||
operation_type = task.task_dict.get("operation_type", "unknown")
|
||||
bank_id = task.task_dict.get("bank_id", "unknown")
|
||||
|
||||
# Create background task
|
||||
bg_task = asyncio.create_task(self._execute_task_inner(task))
|
||||
|
||||
# Track this task as active
|
||||
async with self._in_flight_lock:
|
||||
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema)
|
||||
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema, bg_task)
|
||||
self._in_flight_count += 1
|
||||
self._in_flight_by_type[operation_type] = self._in_flight_by_type.get(operation_type, 0) + 1
|
||||
|
||||
# Add cleanup callback
|
||||
bg_task.add_done_callback(lambda _: asyncio.create_task(self._cleanup_task(task.operation_id, operation_type)))
|
||||
|
||||
async def _cleanup_task(self, operation_id: str, operation_type: str):
|
||||
"""Remove task from tracking after completion."""
|
||||
async with self._in_flight_lock:
|
||||
if operation_id in self._active_tasks:
|
||||
self._active_tasks.pop(operation_id, None)
|
||||
self._in_flight_count -= 1
|
||||
count = self._in_flight_by_type.get(operation_type, 0)
|
||||
if count > 0:
|
||||
self._in_flight_by_type[operation_type] = count - 1
|
||||
if self._in_flight_by_type[operation_type] == 0:
|
||||
del self._in_flight_by_type[operation_type]
|
||||
|
||||
async def _execute_task_inner(self, task: ClaimedTask):
|
||||
"""Inner task execution with error handling."""
|
||||
task_type = task.task_dict.get("type", "unknown")
|
||||
bank_id = task.task_dict.get("bank_id", "unknown")
|
||||
|
||||
try:
|
||||
schema_info = f", schema={task.schema}" if task.schema else ""
|
||||
logger.debug(f"Executing task {task.operation_id} (type={task_type}, bank={bank_id}{schema_info})")
|
||||
# Pass schema to executor so it can set the correct context
|
||||
if task.schema:
|
||||
task.task_dict["_schema"] = task.schema
|
||||
await self._executor(task.task_dict)
|
||||
@@ -279,10 +384,6 @@ class WorkerPoller:
|
||||
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
|
||||
logger.error(f"Task {task.operation_id} failed: {e}")
|
||||
await self._retry_or_fail(task.operation_id, error_msg, task.schema)
|
||||
finally:
|
||||
# Remove from active tasks
|
||||
async with self._in_flight_lock:
|
||||
self._active_tasks.pop(task.operation_id, None)
|
||||
|
||||
async def recover_own_tasks(self) -> int:
|
||||
"""
|
||||
@@ -325,59 +426,59 @@ class WorkerPoller:
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Main polling loop.
|
||||
Main polling loop with fire-and-forget task execution.
|
||||
|
||||
Continuously polls for pending tasks, claims them, and executes them
|
||||
until shutdown is signaled.
|
||||
|
||||
If tenant_extension is configured, dynamically discovers schemas on each poll.
|
||||
Continuously polls for pending tasks, spawns them as background tasks,
|
||||
and immediately continues polling (up to slot limits).
|
||||
"""
|
||||
# Recover any tasks from a previous crash before starting
|
||||
await self.recover_own_tasks()
|
||||
|
||||
logger.info(f"Worker {self._worker_id} starting polling loop")
|
||||
logger.info(
|
||||
f"Worker {self._worker_id} starting polling loop "
|
||||
f"(max_slots={self._max_slots}, consolidation_max_slots={self._consolidation_max_slots})"
|
||||
)
|
||||
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
# Claim a batch of tasks (across all tenant schemas if configured)
|
||||
# Claim a batch of tasks (respecting slot limits)
|
||||
tasks = await self.claim_batch()
|
||||
|
||||
if tasks:
|
||||
# Log batch info
|
||||
task_types: dict[str, int] = {}
|
||||
schemas_seen: set[str | None] = set()
|
||||
consolidation_count = 0
|
||||
for task in tasks:
|
||||
t = task.task_dict.get("type", "unknown")
|
||||
op_type = task.task_dict.get("operation_type", "unknown")
|
||||
task_types[t] = task_types.get(t, 0) + 1
|
||||
schemas_seen.add(task.schema)
|
||||
if op_type == "consolidation":
|
||||
consolidation_count += 1
|
||||
|
||||
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
|
||||
schemas_str = ", ".join(s or "public" for s in schemas_seen)
|
||||
logger.info(
|
||||
f"Worker {self._worker_id} claimed {len(tasks)} tasks: {types_str} (schemas: {schemas_str})"
|
||||
f"Worker {self._worker_id} claimed {len(tasks)} tasks "
|
||||
f"({consolidation_count} consolidation): {types_str} (schemas: {schemas_str})"
|
||||
)
|
||||
|
||||
# Track in-flight tasks
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count += len(tasks)
|
||||
# Spawn tasks as background jobs (fire-and-forget)
|
||||
for task in tasks:
|
||||
await self.execute_task(task)
|
||||
|
||||
# Execute tasks concurrently
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*[self.execute_task(task) for task in tasks],
|
||||
return_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count -= len(tasks)
|
||||
else:
|
||||
# No tasks found, wait before polling again
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._shutdown.wait(),
|
||||
timeout=self._poll_interval_ms / 1000,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Normal timeout, continue polling
|
||||
# Continue immediately to claim more tasks (if slots available)
|
||||
continue
|
||||
|
||||
# No tasks claimed (either no pending tasks or slots full)
|
||||
# Wait before polling again
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._shutdown.wait(),
|
||||
timeout=self._poll_interval_ms / 1000,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Normal timeout, continue polling
|
||||
|
||||
# Log progress stats periodically
|
||||
await self._log_progress_if_due()
|
||||
@@ -408,15 +509,27 @@ class WorkerPoller:
|
||||
while asyncio.get_event_loop().time() - start_time < timeout:
|
||||
async with self._in_flight_lock:
|
||||
in_flight = self._in_flight_count
|
||||
active_task_objects = [task_info[3] for task_info in self._active_tasks.values()]
|
||||
|
||||
if in_flight == 0:
|
||||
logger.info(f"Worker {self._worker_id} graceful shutdown complete")
|
||||
return
|
||||
|
||||
logger.info(f"Worker {self._worker_id} waiting for {in_flight} in-flight tasks")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s")
|
||||
# Wait for at least one task to complete
|
||||
if active_task_objects:
|
||||
done, _ = await asyncio.wait(active_task_objects, timeout=0.5, return_when=asyncio.FIRST_COMPLETED)
|
||||
else:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s, cancelling remaining tasks")
|
||||
|
||||
# Cancel remaining tasks
|
||||
async with self._in_flight_lock:
|
||||
for operation_id, (_, _, _, bg_task) in list(self._active_tasks.items()):
|
||||
if not bg_task.done():
|
||||
bg_task.cancel()
|
||||
|
||||
async def _log_progress_if_due(self):
|
||||
"""Log progress stats every PROGRESS_LOG_INTERVAL seconds."""
|
||||
@@ -427,14 +540,19 @@ class WorkerPoller:
|
||||
self._last_progress_log = now
|
||||
|
||||
try:
|
||||
# Get local active tasks (this worker only)
|
||||
# Get local active tasks
|
||||
async with self._in_flight_lock:
|
||||
in_flight = self._in_flight_count
|
||||
active_tasks = dict(self._active_tasks) # Copy to avoid holding lock
|
||||
in_flight_by_type = dict(self._in_flight_by_type)
|
||||
active_tasks = dict(self._active_tasks)
|
||||
|
||||
# Build local processing breakdown grouped by (op_type, bank_id)
|
||||
consolidation_count = in_flight_by_type.get("consolidation", 0)
|
||||
available_slots = self._max_slots - in_flight
|
||||
available_consolidation_slots = self._consolidation_max_slots - consolidation_count
|
||||
|
||||
# Build local processing breakdown
|
||||
task_groups: dict[tuple[str, str], int] = {}
|
||||
for op_type, bank_id, _ in active_tasks.values():
|
||||
for op_type, bank_id, _, _ in active_tasks.values():
|
||||
key = (op_type, bank_id)
|
||||
task_groups[key] = task_groups.get(key, 0) + 1
|
||||
|
||||
@@ -443,7 +561,7 @@ class WorkerPoller:
|
||||
if len(processing_info) > 10:
|
||||
processing_str += f" +{len(processing_info) - 10} more"
|
||||
|
||||
# Get global stats from DB across all schemas
|
||||
# Get global stats from DB
|
||||
schemas = await self._get_schemas()
|
||||
global_pending = 0
|
||||
all_worker_counts: dict[str, int] = {}
|
||||
@@ -455,7 +573,6 @@ class WorkerPoller:
|
||||
row = await conn.fetchrow(f"SELECT COUNT(*) as count FROM {table} WHERE status = 'pending'")
|
||||
global_pending += row["count"] if row else 0
|
||||
|
||||
# Get processing breakdown by worker
|
||||
worker_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT worker_id, COUNT(*) as count
|
||||
@@ -468,7 +585,6 @@ class WorkerPoller:
|
||||
wid = wr["worker_id"] or "unknown"
|
||||
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
|
||||
|
||||
# Format other workers' processing counts
|
||||
other_workers = []
|
||||
for wid, cnt in all_worker_counts.items():
|
||||
if wid != self._worker_id:
|
||||
@@ -477,7 +593,9 @@ class WorkerPoller:
|
||||
|
||||
schemas_str = ", ".join(s or "public" for s in schemas)
|
||||
logger.info(
|
||||
f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | "
|
||||
f"[WORKER_STATS] worker={self._worker_id} "
|
||||
f"slots={in_flight}/{self._max_slots} (consolidation={consolidation_count}/{self._consolidation_max_slots}) | "
|
||||
f"available={available_slots} (consolidation={available_consolidation_slots}) | "
|
||||
f"global: pending={global_pending} (schemas: {schemas_str}) | "
|
||||
f"others: {others_str} | "
|
||||
f"my_active: {processing_str}"
|
||||
|
||||
@@ -346,11 +346,11 @@ class TestConsolidationIntegration:
|
||||
or when one directly updates another (e.g., location change).
|
||||
|
||||
Given:
|
||||
- "Nicolò lives in Italy"
|
||||
- "Nicolò moved to the US recently" (updates the living location)
|
||||
- "Alex lives in Italy"
|
||||
- "Alex moved to the US recently" (updates the living location)
|
||||
|
||||
The second fact should UPDATE the first, not create a separate observation.
|
||||
But unrelated facts like "Nicolò works at Vectorize" should stay separate.
|
||||
But unrelated facts like "Alex works at Vectorize" should stay separate.
|
||||
"""
|
||||
bank_id = f"test-consolidation-merge-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -360,14 +360,14 @@ class TestConsolidationIntegration:
|
||||
# Retain a memory about living location
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Nicolò lives in Italy.",
|
||||
content="Alex lives in Italy.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Retain an unrelated memory (different topic - should NOT merge)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Nicolò works at Vectorize as an engineer.",
|
||||
content="Alex works at Vectorize as an engineer.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
@@ -384,7 +384,7 @@ class TestConsolidationIntegration:
|
||||
# Add a memory that UPDATES the living location (should merge with first)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Nicolò recently moved to the United States.",
|
||||
content="Alex recently moved to the United States.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
@@ -485,9 +485,9 @@ class TestConsolidationIntegration:
|
||||
they should be merged into ONE observation that captures the change.
|
||||
|
||||
Example:
|
||||
- "Nicolò loves pizza"
|
||||
- "Nicolò hates pizza"
|
||||
→ Should become: "Nicolò used to love pizza but now hates it" (or similar)
|
||||
- "Alex loves pizza"
|
||||
- "Alex hates pizza"
|
||||
→ Should become: "Alex used to love pizza but now hates it" (or similar)
|
||||
"""
|
||||
bank_id = f"test-consolidation-contradict-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
@@ -497,7 +497,7 @@ class TestConsolidationIntegration:
|
||||
# Add initial fact
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Nicolò loves pizza.",
|
||||
content="Alex loves pizza.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
@@ -515,7 +515,7 @@ class TestConsolidationIntegration:
|
||||
# Add contradicting fact (same person, same topic, opposite sentiment)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Nicolò hates pizza.",
|
||||
content="Alex hates pizza.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +156,6 @@ class TestWorkerPoller:
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=mock_executor,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
@@ -177,8 +176,8 @@ class TestWorkerPoller:
|
||||
assert row["worker_id"] == "test-worker-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_batch_respects_batch_size(self, pool, clean_operations):
|
||||
"""Test that claim_batch respects the batch_size limit."""
|
||||
async def test_claim_batch_respects_max_slots(self, pool, clean_operations):
|
||||
"""Test that claim_batch respects the max_slots limit."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create 10 pending tasks
|
||||
@@ -196,12 +195,11 @@ class TestWorkerPoller:
|
||||
payload,
|
||||
)
|
||||
|
||||
# Claim with batch_size=3
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=3,
|
||||
max_slots=3, # Limit to 3 concurrent tasks
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
@@ -238,11 +236,14 @@ class TestWorkerPoller:
|
||||
executor=mock_executor,
|
||||
)
|
||||
|
||||
# Execute the task
|
||||
# Execute the task (fire-and-forget)
|
||||
task_dict = json.loads(payload)
|
||||
claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None)
|
||||
await poller.execute_task(claimed_task)
|
||||
|
||||
# Wait for background task to complete
|
||||
completed = await poller.wait_for_active_tasks(timeout=5.0)
|
||||
assert completed, "Task did not complete within timeout"
|
||||
assert len(executed) == 1
|
||||
|
||||
# Verify task is marked as completed
|
||||
@@ -283,11 +284,15 @@ class TestWorkerPoller:
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Execute (should fail and retry)
|
||||
# Execute (should fail and retry) - fire-and-forget
|
||||
task_dict = json.loads(payload)
|
||||
claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None)
|
||||
await poller.execute_task(claimed_task)
|
||||
|
||||
# Wait for background task to complete
|
||||
completed = await poller.wait_for_active_tasks(timeout=5.0)
|
||||
assert completed, "Task did not complete within timeout"
|
||||
|
||||
# Verify task is back to pending with incremented retry_count
|
||||
row = await pool.fetchrow(
|
||||
"SELECT status, retry_count, worker_id FROM async_operations WHERE operation_id = $1",
|
||||
@@ -327,11 +332,15 @@ class TestWorkerPoller:
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Execute (should fail permanently)
|
||||
# Execute (should fail permanently) - fire-and-forget
|
||||
task_dict = json.loads(payload)
|
||||
claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None)
|
||||
await poller.execute_task(claimed_task)
|
||||
|
||||
# Wait for background task to complete
|
||||
completed = await poller.wait_for_active_tasks(timeout=5.0)
|
||||
assert completed, "Task did not complete within timeout"
|
||||
|
||||
# Verify task is marked as failed
|
||||
row = await pool.fetchrow(
|
||||
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
|
||||
@@ -388,7 +397,6 @@ class TestWorkerPoller:
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
@@ -440,7 +448,6 @@ class TestWorkerPoller:
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
@@ -607,7 +614,6 @@ class TestConcurrentWorkers:
|
||||
pool=pool,
|
||||
worker_id=worker_id,
|
||||
executor=lambda x: None,
|
||||
batch_size=5, # Each worker tries to claim 5
|
||||
)
|
||||
claimed = await poller.claim_batch()
|
||||
workers_claimed[worker_id] = [task.operation_id for task in claimed]
|
||||
@@ -680,7 +686,6 @@ class TestConcurrentWorkers:
|
||||
pool=pool,
|
||||
worker_id="new-worker",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
@@ -879,7 +884,6 @@ class TestDynamicTenantDiscovery:
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
tenant_extension=mock_extension,
|
||||
)
|
||||
|
||||
@@ -946,7 +950,6 @@ class TestDynamicTenantDiscovery:
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
tenant_extension=dynamic_extension,
|
||||
)
|
||||
|
||||
@@ -1008,7 +1011,6 @@ class TestDynamicTenantDiscovery:
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
@@ -1017,3 +1019,198 @@ class TestDynamicTenantDiscovery:
|
||||
# All tasks should have schema=None (public)
|
||||
for task in claimed:
|
||||
assert task.schema is None
|
||||
|
||||
|
||||
async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
"""
|
||||
Test that worker continues polling while tasks run (fire-and-forget pattern).
|
||||
|
||||
This test verifies the FIX: With the old blocking behavior, the worker would
|
||||
wait for all tasks in a batch to complete before claiming more. This test
|
||||
would FAIL with the old code because tasks 3-4 wouldn't be claimed until
|
||||
tasks 1-2 complete. With fire-and-forget, tasks 3-4 are claimed immediately.
|
||||
"""
|
||||
from hindsight_api.worker.poller import WorkerPoller
|
||||
|
||||
task_started = {} # operation_id -> Event (set when task starts)
|
||||
task_canfinish = {} # operation_id -> Event (wait before finishing)
|
||||
|
||||
async def blocking_executor(task_dict: dict):
|
||||
op_id = task_dict["operation_id"]
|
||||
# Signal that this task has started
|
||||
started = asyncio.Event()
|
||||
task_started[op_id] = started
|
||||
started.set()
|
||||
|
||||
# Block until we're told to finish
|
||||
finish = asyncio.Event()
|
||||
task_canfinish[op_id] = finish
|
||||
await finish.wait()
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker",
|
||||
executor=blocking_executor,
|
||||
poll_interval_ms=50, # Fast polling
|
||||
max_slots=10,
|
||||
consolidation_max_slots=2,
|
||||
)
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Submit initial 2 tasks
|
||||
task_ids = []
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
poll_task = asyncio.create_task(poller.run())
|
||||
|
||||
try:
|
||||
# Wait for first 2 tasks to start executing (but not finish)
|
||||
for i in range(100): # Try for up to 1 second
|
||||
if len(task_started) >= 2:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert len(task_started) == 2, f"Expected 2 tasks started, got {len(task_started)}"
|
||||
|
||||
# Verify tasks are in_flight
|
||||
async with poller._in_flight_lock:
|
||||
assert poller._in_flight_count == 2
|
||||
|
||||
# NOW submit 2 more tasks WHILE the first 2 are still running
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# KEY ASSERTION: Worker should claim tasks 3-4 WITHOUT waiting for 1-2 to finish
|
||||
# This would FAIL with the old blocking behavior
|
||||
for i in range(100): # Try for up to 1 second
|
||||
if len(task_started) >= 4:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert len(task_started) == 4, (
|
||||
f"Fire-and-forget FAILED: Expected 4 tasks started, got {len(task_started)}. "
|
||||
"This means the worker blocked waiting for the first batch to complete."
|
||||
)
|
||||
|
||||
# Verify all 4 tasks are in-flight
|
||||
async with poller._in_flight_lock:
|
||||
assert poller._in_flight_count == 4
|
||||
|
||||
# Clean up: allow all tasks to finish
|
||||
for event in task_canfinish.values():
|
||||
event.set()
|
||||
|
||||
finally:
|
||||
# Ensure cleanup
|
||||
for event in task_canfinish.values():
|
||||
event.set()
|
||||
await poller.shutdown_graceful(timeout=2.0)
|
||||
try:
|
||||
await asyncio.wait_for(poll_task, timeout=1.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
"""Test that worker respects max_slots and won't exceed the limit."""
|
||||
from hindsight_api.worker.poller import WorkerPoller
|
||||
|
||||
tasks_started = set()
|
||||
task_events = {}
|
||||
|
||||
async def controlled_executor(task_dict: dict):
|
||||
op_id = task_dict["operation_id"]
|
||||
tasks_started.add(op_id)
|
||||
event = asyncio.Event()
|
||||
task_events[op_id] = event
|
||||
await event.wait()
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker",
|
||||
executor=controlled_executor,
|
||||
poll_interval_ms=50,
|
||||
max_slots=3, # Only allow 3 concurrent tasks
|
||||
consolidation_max_slots=1,
|
||||
)
|
||||
|
||||
# Submit 10 tasks
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
for i in range(10):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
poll_task = asyncio.create_task(poller.run())
|
||||
|
||||
try:
|
||||
# Wait for slots to fill
|
||||
for i in range(100):
|
||||
if len(tasks_started) >= 3:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Should have claimed exactly 3 tasks (slot limit)
|
||||
assert len(tasks_started) == 3
|
||||
|
||||
# Wait to ensure no additional tasks are claimed
|
||||
for i in range(30):
|
||||
await asyncio.sleep(0.01)
|
||||
assert len(tasks_started) == 3, "Worker exceeded slot limit!"
|
||||
|
||||
# Release tasks one by one and verify remaining are claimed
|
||||
completed = 0
|
||||
while completed < 10 and len(tasks_started) < 10:
|
||||
# Release the next batch
|
||||
events_to_release = list(task_events.values())[completed:completed+3]
|
||||
for event in events_to_release:
|
||||
event.set()
|
||||
completed += len(events_to_release)
|
||||
|
||||
# Wait for new tasks to be claimed
|
||||
for i in range(100):
|
||||
if len(tasks_started) >= min(completed + 3, 10):
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert len(tasks_started) == 10
|
||||
|
||||
finally:
|
||||
for event in task_events.values():
|
||||
event.set()
|
||||
await poller.shutdown_graceful(timeout=2.0)
|
||||
try:
|
||||
await asyncio.wait_for(poll_task, timeout=1.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -504,9 +504,10 @@ Configuration for background task processing. By default, the API processes task
|
||||
| `HINDSIGHT_API_WORKER_ENABLED` | Enable internal worker in API process | `true` |
|
||||
| `HINDSIGHT_API_WORKER_ID` | Unique worker identifier | hostname |
|
||||
| `HINDSIGHT_API_WORKER_POLL_INTERVAL_MS` | Database polling interval in milliseconds | `500` |
|
||||
| `HINDSIGHT_API_WORKER_BATCH_SIZE` | Tasks to claim per poll cycle | `10` |
|
||||
| `HINDSIGHT_API_WORKER_MAX_RETRIES` | Max retries before marking task failed | `3` |
|
||||
| `HINDSIGHT_API_WORKER_HTTP_PORT` | HTTP port for worker metrics/health (worker CLI only) | `8889` |
|
||||
| `HINDSIGHT_API_WORKER_MAX_SLOTS` | Maximum concurrent tasks per worker | `10` |
|
||||
| `HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLOTS` | Maximum concurrent consolidation tasks per worker | `2` |
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
|
||||
+32
-79
@@ -2,23 +2,24 @@
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# OpenClawd
|
||||
# Moltbot (Clawdbot)
|
||||
|
||||
Biomimetic long-term memory for [OpenClawd](https://openclawd.ai) using [Hindsight](https://vectorize.io/hindsight).
|
||||
|
||||
This plugin integrates [hindsight-embed](https://vectorize.io/hindsight/cli), a standalone daemon that bundles Hindsight's memory engine (API + PostgreSQL) into a single command. The plugin automatically manages the daemon lifecycle and provides hooks for seamless memory capture and recall.
|
||||
Biomimetic long-term memory for [Moltbot](https://molt.bot) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure your LLM provider
|
||||
# 1. Install the plugin
|
||||
npm install -g @vectorize-io/hindsight-moltbot-plugin
|
||||
|
||||
# 2. Configure your LLM provider
|
||||
export OPENAI_API_KEY="sk-your-key"
|
||||
clawdbot config set 'agents.defaults.models."openai/gpt-4o-mini"' '{}'
|
||||
|
||||
# 2. Install and enable the plugin
|
||||
clawdbot plugins install @vectorize-io/hindsight-openclawd
|
||||
# 3. Enable the plugin
|
||||
clawdbot plugins enable hindsight-memory
|
||||
|
||||
# 3. Start OpenClawd
|
||||
# 4. Start Moltbot
|
||||
clawdbot gateway
|
||||
```
|
||||
|
||||
@@ -38,10 +39,10 @@ Before each agent response, relevant memories are **automatically injected**:
|
||||
- Injected into context with `<hindsight-context>` tags
|
||||
- Agent seamlessly uses past context
|
||||
|
||||
## Understanding OpenClawd Concepts
|
||||
## Understanding Moltbot Concepts
|
||||
|
||||
### Plugins
|
||||
Extensions that add functionality to OpenClawd. This Hindsight plugin:
|
||||
Extensions that add functionality to Moltbot. This Hindsight plugin:
|
||||
- Runs a background service (manages `hindsight-embed` daemon)
|
||||
- Registers hooks (automatic event handlers)
|
||||
|
||||
@@ -56,7 +57,7 @@ Think of hooks as "forced automation" - they always run.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ OpenClawd Gateway │
|
||||
│ Moltbot Gateway │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ Hindsight Plugin │ │
|
||||
@@ -71,33 +72,33 @@ Think of hooks as "forced automation" - they always run.
|
||||
↓
|
||||
uvx hindsight-embed
|
||||
• Daemon on port 8889
|
||||
• PostgreSQL (pg0://hindsight-embed)
|
||||
• Bank: 'openclawd' (isolated within shared database)
|
||||
• PostgreSQL (pg0)
|
||||
• Fact extraction
|
||||
```
|
||||
|
||||
**Database Architecture:** All banks share a single pg0 database instance (`pg0://hindsight-embed`). Bank isolation happens within the database via separate tables/schemas per bank ID. The 'openclawd' bank is automatically created when the plugin stores its first memory.
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** 22+
|
||||
- **OpenClawd** (Clawdbot) with plugin support
|
||||
- **Moltbot** (Clawdbot) with plugin support
|
||||
- **uv/uvx** for running `hindsight-embed`
|
||||
- **LLM API key** (OpenAI, Anthropic, etc.)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# 1. Configure your LLM provider
|
||||
# 1. Install the plugin
|
||||
npm install -g @vectorize-io/hindsight-moltbot-plugin
|
||||
|
||||
# 2. Configure your LLM provider
|
||||
export OPENAI_API_KEY="sk-your-key"
|
||||
clawdbot config set 'agents.defaults.models."openai/gpt-4o-mini"' '{}'
|
||||
|
||||
# 2. Install and enable the plugin
|
||||
clawdbot plugins install @vectorize-io/hindsight-openclawd
|
||||
# 3. Enable the plugin
|
||||
clawdbot plugins enable hindsight-memory
|
||||
|
||||
# 3. Start OpenClawd
|
||||
# 4. Start Moltbot
|
||||
clawdbot gateway
|
||||
```
|
||||
|
||||
@@ -112,7 +113,7 @@ Optional settings in `~/.clawdbot/clawdbot.json`:
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"hindsight-openclawd": {
|
||||
"hindsight-memory": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"daemonIdleTimeout": 0
|
||||
@@ -127,7 +128,6 @@ Optional settings in `~/.clawdbot/clawdbot.json`:
|
||||
- `daemonIdleTimeout` (number, default: `0`) - Seconds before daemon shuts down from inactivity (0 = never)
|
||||
- `embedPort` (number, default: auto) - Port for embedded server
|
||||
- `bankMission` (string, default: none) - Custom context for the memory bank
|
||||
- `embedVersion` (string, default: `"latest"`) - hindsight-embed version to use (e.g., `"latest"`, `"0.4.2"`, or leave empty for latest). Use this to pin a specific version if latest is broken.
|
||||
|
||||
## Supported LLM Providers
|
||||
|
||||
@@ -156,7 +156,7 @@ clawdbot plugins list | grep hindsight
|
||||
```
|
||||
|
||||
**Test auto-recall:**
|
||||
Send a message on any OpenClawd channel (Telegram, Slack, etc.):
|
||||
Send a message on any Moltbot channel (Telegram, Slack, etc.):
|
||||
```
|
||||
User: My name is John and I love pizza
|
||||
Bot: Got it! I'll remember that.
|
||||
@@ -172,55 +172,7 @@ tail -f ~/.hindsight/daemon.log
|
||||
|
||||
**Check memories in database:**
|
||||
```bash
|
||||
uvx hindsight-embed@latest memory recall openclawd "pizza" --output json
|
||||
```
|
||||
|
||||
## Inspecting Memories
|
||||
|
||||
The plugin uses `hindsight-embed` daemon which provides CLI commands for inspection:
|
||||
|
||||
**View daemon logs:**
|
||||
```bash
|
||||
uvx hindsight-embed@latest daemon logs
|
||||
# Or follow logs in real-time:
|
||||
tail -f ~/.hindsight/daemon.log
|
||||
```
|
||||
|
||||
**Open web UI:**
|
||||
```bash
|
||||
uvx hindsight-embed@latest ui
|
||||
# Opens browser to http://localhost:8890
|
||||
# Browse memories, facts, entities, and relationships
|
||||
```
|
||||
|
||||
**List memory banks:**
|
||||
```bash
|
||||
uvx hindsight-embed@latest bank list
|
||||
# Shows all banks including 'openclawd'
|
||||
```
|
||||
|
||||
**Query memories:**
|
||||
```bash
|
||||
# Search memories
|
||||
uvx hindsight-embed@latest memory recall openclawd "user preferences" --output json
|
||||
|
||||
# View recent memories
|
||||
uvx hindsight-embed@latest memory list openclawd --limit 10
|
||||
|
||||
# Export all memories
|
||||
uvx hindsight-embed@latest memory export openclawd --output memories.json
|
||||
```
|
||||
|
||||
**Inspect facts and entities:**
|
||||
```bash
|
||||
# List extracted facts
|
||||
uvx hindsight-embed@latest fact list openclawd
|
||||
|
||||
# List entities
|
||||
uvx hindsight-embed@latest entity list openclawd
|
||||
|
||||
# Show entity relationships
|
||||
uvx hindsight-embed@latest entity graph openclawd
|
||||
uvx hindsight-embed memory recall moltbot "pizza" --output json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -228,19 +180,20 @@ uvx hindsight-embed@latest entity graph openclawd
|
||||
**Plugin not loading?**
|
||||
```bash
|
||||
# Check plugin installation
|
||||
clawdbot plugins list | grep -i hindsight
|
||||
npm list -g @vectorize-io/hindsight-moltbot-plugin
|
||||
|
||||
# Reinstall if needed
|
||||
clawdbot plugins install @vectorize-io/hindsight-openclawd
|
||||
npm install -g @vectorize-io/hindsight-moltbot-plugin
|
||||
clawdbot plugins enable hindsight-memory
|
||||
```
|
||||
|
||||
**Daemon not starting?**
|
||||
```bash
|
||||
# Check daemon status
|
||||
uvx hindsight-embed@latest daemon status
|
||||
uvx hindsight-embed daemon status
|
||||
|
||||
# Manually start
|
||||
uvx hindsight-embed@latest daemon start
|
||||
uvx hindsight-embed daemon start
|
||||
|
||||
# View logs
|
||||
tail -f ~/.hindsight/daemon.log
|
||||
@@ -271,7 +224,7 @@ tail -f /tmp/clawdbot/clawdbot-*.log | grep Hindsight
|
||||
```bash
|
||||
# Clone repo
|
||||
git clone https://github.com/vectorize-io/hindsight.git
|
||||
cd hindsight/hindsight-integrations/openclawd
|
||||
cd hindsight/hindsight-integrations/moltbot
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
@@ -289,7 +242,7 @@ npm run build && ./install.sh
|
||||
## Requirements
|
||||
|
||||
- **Node.js** 22+
|
||||
- **OpenClawd** (Clawdbot) with plugin support
|
||||
- **Moltbot** (Clawdbot) with plugin support
|
||||
- **uv/uvx** for running `hindsight-embed`
|
||||
- **LLM API key** (OpenAI, Anthropic, etc.)
|
||||
|
||||
@@ -300,5 +253,5 @@ MIT
|
||||
## Links
|
||||
|
||||
- [Hindsight Documentation](https://vectorize.io/hindsight)
|
||||
- [OpenClawd Documentation](https://openclawd.ai)
|
||||
- [Moltbot Documentation](https://docs.molt.bot)
|
||||
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
|
||||
@@ -120,9 +120,7 @@ Run `hindsight-embed configure` for a guided setup that saves to `~/.hindsight/e
|
||||
| `HINDSIGHT_EMBED_LLM_API_KEY` | LLM API key (or use `OPENAI_API_KEY`) | Required |
|
||||
| `HINDSIGHT_EMBED_LLM_PROVIDER` | LLM provider (`openai`, `groq`, `google`, `ollama`) | `openai` |
|
||||
| `HINDSIGHT_EMBED_LLM_MODEL` | LLM model | `gpt-4o-mini` |
|
||||
| `HINDSIGHT_EMBED_BANK_ID` | Default memory bank ID (optional, used when not specified in CLI) | `default` |
|
||||
|
||||
**Note:** All banks share a single pg0 database (`pg0://hindsight-embed`). Bank isolation happens within the database via the `bank_id` parameter passed to CLI commands.
|
||||
| `HINDSIGHT_EMBED_BANK_ID` | Memory bank ID | `default` |
|
||||
|
||||
### Files
|
||||
|
||||
|
||||
@@ -71,8 +71,9 @@ def _start_daemon(config: dict) -> bool:
|
||||
if config.get("llm_model"):
|
||||
env["HINDSIGHT_API_LLM_MODEL"] = config["llm_model"]
|
||||
|
||||
# Use single shared pg0 database for all banks (banks are isolated within the database)
|
||||
env["HINDSIGHT_API_DATABASE_URL"] = "pg0://hindsight-embed"
|
||||
# Use pg0 database specific to bank
|
||||
bank_id = config.get("bank_id", "default")
|
||||
env["HINDSIGHT_API_DATABASE_URL"] = f"pg0://hindsight-embed-{bank_id}"
|
||||
env["HINDSIGHT_API_LOG_LEVEL"] = "info"
|
||||
|
||||
# Get idle timeout from environment or use default
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Hindsight Memory Plugin for Moltbot
|
||||
|
||||
Biomimetic long-term memory for [Moltbot](https://molt.bot) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Install the plugin
|
||||
npm install -g @vectorize-io/hindsight-moltbot-plugin
|
||||
|
||||
# 2. Configure your LLM provider
|
||||
export OPENAI_API_KEY="sk-your-key"
|
||||
clawdbot config set 'agents.defaults.models."openai/gpt-4o-mini"' '{}'
|
||||
|
||||
# 3. Enable the plugin
|
||||
clawdbot plugins enable hindsight-memory
|
||||
|
||||
# 4. Start Moltbot
|
||||
clawdbot gateway
|
||||
```
|
||||
|
||||
That's it! The plugin will automatically start capturing and recalling memories.
|
||||
|
||||
## Documentation
|
||||
|
||||
For full documentation, configuration options, troubleshooting, and development guide, see:
|
||||
|
||||
**[Moltbot Integration Documentation](https://vectorize.io/hindsight/sdks/integrations/moltbot)**
|
||||
|
||||
## Links
|
||||
|
||||
- [Hindsight Documentation](https://vectorize.io/hindsight)
|
||||
- [Moltbot Documentation](https://docs.molt.bot)
|
||||
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "hindsight-memory",
|
||||
"name": "Hindsight Memory",
|
||||
"kind": "memory",
|
||||
"moltbot": {
|
||||
"skills": ["skills"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bankMission": {
|
||||
"type": "string",
|
||||
"description": "Custom mission/context for the memory bank (overrides default)"
|
||||
},
|
||||
"embedPort": {
|
||||
"type": "number",
|
||||
"description": "Port for hindsight-embed server (auto-assigned if not specified)",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"uiHints": {
|
||||
"bankMission": {
|
||||
"label": "Bank Mission",
|
||||
"placeholder": "Custom context for what this agent does..."
|
||||
},
|
||||
"embedPort": {
|
||||
"label": "Embed Server Port",
|
||||
"placeholder": "0 (auto-assign)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: hindsight-retain-messages
|
||||
description: Automatically retains messages to Hindsight long-term memory
|
||||
events:
|
||||
- agent_end
|
||||
metadata:
|
||||
moltbot:
|
||||
emoji: 🧠
|
||||
---
|
||||
|
||||
# Hindsight Message Retention
|
||||
|
||||
This hook automatically retains conversation messages to Hindsight's long-term memory.
|
||||
|
||||
## When It Runs
|
||||
|
||||
- On `agent_end`: After each agent turn completes
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Captures the current session messages
|
||||
2. Formats them into a conversation transcript
|
||||
3. Calls Hindsight's retain API with the session_id as document_id
|
||||
4. Queues for background processing (async)
|
||||
5. Extracts facts, entities, and relationships from the conversation
|
||||
@@ -0,0 +1,68 @@
|
||||
// Handler for auto-retaining messages to Hindsight
|
||||
const handler = async (event) => {
|
||||
console.log(`[Hindsight Hook] Received event: ${event.type}`);
|
||||
|
||||
// Only process agent_end events (after each agent turn)
|
||||
if (event.type !== 'agent_end') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Hindsight Hook] Processing retention after agent turn...');
|
||||
|
||||
try {
|
||||
// Get client from global (set by main plugin)
|
||||
const clientGlobal = global.__hindsightClient;
|
||||
if (!clientGlobal) {
|
||||
console.warn('[Hindsight] Client global not found, skipping retain');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = clientGlobal.getClient();
|
||||
if (!client) {
|
||||
console.warn('[Hindsight] Client not initialized, skipping retain');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract session information
|
||||
const { sessionId, sessionKey } = event.context || {};
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get messages from the event context
|
||||
const sessionEntry = event.context?.sessionEntry;
|
||||
if (!sessionEntry || !sessionEntry.messages || sessionEntry.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format messages into a transcript
|
||||
const transcript = sessionEntry.messages
|
||||
.map((msg) => {
|
||||
const role = msg.role || 'unknown';
|
||||
const content = msg.content || '';
|
||||
return `${role}: ${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
if (!transcript.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retain to Hindsight with session_id as document_id
|
||||
await client.retain({
|
||||
content: transcript,
|
||||
document_id: sessionId,
|
||||
metadata: {
|
||||
session_key: sessionKey,
|
||||
retained_at: new Date().toISOString(),
|
||||
message_count: sessionEntry.messages.length,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Hindsight] Retained ${sessionEntry.messages.length} messages for session ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Error retaining messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1,71 @@
|
||||
// Handler for auto-retaining messages to Hindsight
|
||||
|
||||
import type { HookHandler } from 'moltbot/plugin-sdk';
|
||||
|
||||
const handler: HookHandler = async (event) => {
|
||||
// Only process tool_result_persist and command:new events
|
||||
if (
|
||||
event.type !== 'tool_result_persist' &&
|
||||
!(event.type === 'command' && event.action === 'new')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get client from global (set by main plugin)
|
||||
const clientGlobal = (global as any).__hindsightClient;
|
||||
if (!clientGlobal) {
|
||||
console.warn('[Hindsight] Client global not found, skipping retain');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = clientGlobal.getClient();
|
||||
if (!client) {
|
||||
console.warn('[Hindsight] Client not initialized, skipping retain');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract session information
|
||||
const { sessionId, sessionKey } = event.context || {};
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get messages from the event context
|
||||
// The messages are in event.context.sessionEntry or similar
|
||||
const sessionEntry = event.context?.sessionEntry;
|
||||
if (!sessionEntry || !sessionEntry.messages || sessionEntry.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format messages into a transcript
|
||||
const transcript = sessionEntry.messages
|
||||
.map((msg: any) => {
|
||||
const role = msg.role || 'unknown';
|
||||
const content = msg.content || '';
|
||||
return `${role}: ${content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
if (!transcript.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retain to Hindsight with session_id as document_id
|
||||
await client.retain({
|
||||
content: transcript,
|
||||
document_id: sessionId,
|
||||
metadata: {
|
||||
session_key: sessionKey,
|
||||
retained_at: new Date().toISOString(),
|
||||
message_count: sessionEntry.messages.length,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Hindsight] Retained ${sessionEntry.messages.length} messages for session ${sessionId}`);
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Error retaining messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
+4
-4
@@ -5,7 +5,7 @@ echo "🚀 Installing Hindsight Memory Plugin for Moltbot..."
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
INSTALL_DIR="$HOME/.clawdbot/extensions/hindsight-openclawd"
|
||||
INSTALL_DIR="$HOME/.clawdbot/extensions/hindsight-memory"
|
||||
|
||||
# Check Node version
|
||||
if ! command -v node &> /dev/null; then
|
||||
@@ -25,7 +25,7 @@ rm -rf "$INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Copy files
|
||||
cp -r dist package.json clawdbot.plugin.json README.md "$INSTALL_DIR/"
|
||||
cp -r dist package.json clawdbot.plugin.json hooks README.md "$INSTALL_DIR/"
|
||||
|
||||
# Install dependencies in deployed location
|
||||
echo "📥 Installing dependencies..."
|
||||
@@ -41,9 +41,9 @@ echo "1. Make sure you have an OpenAI API key set:"
|
||||
echo " export OPENAI_API_KEY=\"sk-your-key-here\""
|
||||
echo ""
|
||||
echo "2. Enable the plugin:"
|
||||
echo " clawdbot plugins enable hindsight-openclawd"
|
||||
echo " clawdbot plugins enable hindsight-memory"
|
||||
echo ""
|
||||
echo "3. Start OpenClawd:"
|
||||
echo "3. Start Moltbot:"
|
||||
echo " clawdbot start"
|
||||
echo ""
|
||||
echo "On first start, uvx will automatically download hindsight-embed (no manual install needed)"
|
||||
+10
-5
@@ -1,17 +1,21 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-openclawd",
|
||||
"version": "0.0.5",
|
||||
"description": "Hindsight memory plugin for OpenClawd - biomimetic long-term memory with fact extraction",
|
||||
"name": "@vectorize-io/hindsight-moltbot-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Hindsight memory plugin for Moltbot - biomimetic long-term memory with fact extraction",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"hooks": [
|
||||
"hooks/retain-messages"
|
||||
]
|
||||
},
|
||||
"keywords": [
|
||||
"openclawd",
|
||||
"moltbot",
|
||||
"clawdbot",
|
||||
"memory",
|
||||
"ai",
|
||||
"agent",
|
||||
@@ -23,11 +27,12 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vectorize-io/hindsight.git",
|
||||
"directory": "hindsight-integrations/openclawd"
|
||||
"directory": "hindsight-integrations/moltbot"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"clawdbot.plugin.json",
|
||||
"hooks",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: memory_search
|
||||
description: Search your long-term memory for relevant facts, experiences, and context using semantic and graph-based retrieval
|
||||
user-invocable: false
|
||||
disable-model-invocation: false
|
||||
---
|
||||
|
||||
# memory_search
|
||||
|
||||
Search your long-term memory for relevant information. This tool provides multi-strategy retrieval combining:
|
||||
- Semantic search across facts and experiences
|
||||
- BM25 keyword matching
|
||||
- Entity graph traversal
|
||||
- Temporal queries
|
||||
- Cross-encoder reranking
|
||||
|
||||
## Usage
|
||||
|
||||
Call `memory_search` with a natural language query to find relevant memories:
|
||||
|
||||
```
|
||||
memory_search "What does the user prefer for breakfast?"
|
||||
memory_search "When did we discuss the project deadline?"
|
||||
memory_search "Tell me about Paris"
|
||||
```
|
||||
|
||||
## Returns
|
||||
|
||||
Returns a list of relevant memory fragments with:
|
||||
- Content: The actual memory text
|
||||
- Score: Relevance score (0-1)
|
||||
- Metadata: Source document, creation date, entities
|
||||
|
||||
Use the results to inform your responses with context from past conversations.
|
||||
@@ -0,0 +1,46 @@
|
||||
// Handler for memory_search tool
|
||||
// This will be called when the agent invokes memory_search
|
||||
|
||||
import { getClient } from '../../src/index.js';
|
||||
|
||||
export interface ToolContext {
|
||||
query: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function handle(ctx: ToolContext): Promise<string> {
|
||||
try {
|
||||
const { query } = ctx;
|
||||
|
||||
const client = getClient();
|
||||
if (!client) {
|
||||
throw new Error('Hindsight client not initialized');
|
||||
}
|
||||
|
||||
// Call Hindsight recall API
|
||||
const response = await client.recall({
|
||||
query,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// Format results for the agent
|
||||
if (!response.results || response.results.length === 0) {
|
||||
return 'No relevant memories found for this query.';
|
||||
}
|
||||
|
||||
const formatted = response.results
|
||||
.map((result: any, idx: number) => {
|
||||
const score = result.score ? ` (relevance: ${result.score.toFixed(2)})` : '';
|
||||
const date = result.metadata?.created_at
|
||||
? ` [${new Date(result.metadata.created_at).toLocaleDateString()}]`
|
||||
: '';
|
||||
return `${idx + 1}. ${result.content}${score}${date}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return `Found ${response.results.length} relevant memories:\n\n${formatted}`;
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] memory_search error:', error);
|
||||
return `Error searching memories: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
+3
-25
@@ -15,37 +15,17 @@ export class HindsightClient {
|
||||
private llmProvider: string;
|
||||
private llmApiKey: string;
|
||||
private llmModel?: string;
|
||||
private embedVersion: string;
|
||||
|
||||
constructor(llmProvider: string, llmApiKey: string, llmModel?: string, embedVersion: string = 'latest') {
|
||||
constructor(llmProvider: string, llmApiKey: string, llmModel?: string) {
|
||||
this.llmProvider = llmProvider;
|
||||
this.llmApiKey = llmApiKey;
|
||||
this.llmModel = llmModel;
|
||||
this.embedVersion = embedVersion || 'latest';
|
||||
}
|
||||
|
||||
setBankId(bankId: string): void {
|
||||
this.bankId = bankId;
|
||||
}
|
||||
|
||||
async setBankMission(mission: string): Promise<void> {
|
||||
if (!mission || mission.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const escapedMission = mission.replace(/'/g, "'\\''"); // Escape single quotes
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const cmd = `uvx ${embedPackage} bank mission ${this.bankId} '${escapedMission}'`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { env: this.getEnv() });
|
||||
console.log(`[Hindsight] Bank mission set: ${stdout.trim()}`);
|
||||
} catch (error) {
|
||||
// Don't fail if mission set fails - bank might not exist yet, will be created on first retain
|
||||
console.warn(`[Hindsight] Could not set bank mission (bank may not exist yet): ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
@@ -64,8 +44,7 @@ export class HindsightClient {
|
||||
const content = request.content.replace(/'/g, "'\\''"); // Escape single quotes
|
||||
const docId = request.document_id || 'conversation';
|
||||
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const cmd = `uvx ${embedPackage} memory retain ${this.bankId} '${content}' --doc-id '${docId}' --async`;
|
||||
const cmd = `uvx hindsight-embed memory retain ${this.bankId} '${content}' --doc-id '${docId}' --async`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { env: this.getEnv() });
|
||||
@@ -86,8 +65,7 @@ export class HindsightClient {
|
||||
const query = request.query.replace(/'/g, "'\\''"); // Escape single quotes
|
||||
const maxTokens = request.max_tokens || 1024;
|
||||
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const cmd = `uvx ${embedPackage} memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`;
|
||||
const cmd = `uvx hindsight-embed memory recall ${this.bankId} '${query}' --output json --max-tokens ${maxTokens}`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, { env: this.getEnv() });
|
||||
+3
-8
@@ -13,15 +13,13 @@ export class HindsightEmbedManager {
|
||||
private llmApiKey: string;
|
||||
private llmModel?: string;
|
||||
private daemonIdleTimeout: number;
|
||||
private embedVersion: string;
|
||||
|
||||
constructor(
|
||||
port: number,
|
||||
llmProvider: string,
|
||||
llmApiKey: string,
|
||||
llmModel?: string,
|
||||
daemonIdleTimeout: number = 0, // Default: never timeout
|
||||
embedVersion: string = 'latest' // Default: latest
|
||||
daemonIdleTimeout: number = 0 // Default: never timeout
|
||||
) {
|
||||
this.port = 8889; // hindsight-embed uses fixed port 8889
|
||||
this.baseUrl = `http://127.0.0.1:8889`;
|
||||
@@ -30,7 +28,6 @@ export class HindsightEmbedManager {
|
||||
this.llmApiKey = llmApiKey;
|
||||
this.llmModel = llmModel;
|
||||
this.daemonIdleTimeout = daemonIdleTimeout;
|
||||
this.embedVersion = embedVersion || 'latest';
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -49,10 +46,9 @@ export class HindsightEmbedManager {
|
||||
}
|
||||
|
||||
// Start hindsight-embed daemon (it manages itself)
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const startDaemon = spawn(
|
||||
'uvx',
|
||||
[embedPackage, 'daemon', 'start'],
|
||||
['hindsight-embed', 'daemon', 'start'],
|
||||
{
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
@@ -97,8 +93,7 @@ export class HindsightEmbedManager {
|
||||
async stop(): Promise<void> {
|
||||
console.log('[Hindsight] Stopping hindsight-embed daemon...');
|
||||
|
||||
const embedPackage = this.embedVersion ? `hindsight-embed@${this.embedVersion}` : 'hindsight-embed@latest';
|
||||
const stopDaemon = spawn('uvx', [embedPackage, 'daemon', 'stop'], {
|
||||
const stopDaemon = spawn('uvx', ['hindsight-embed', 'daemon', 'stop'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
+50
-73
@@ -7,17 +7,11 @@ import { fileURLToPath } from 'url';
|
||||
// Module-level state
|
||||
let embedManager: HindsightEmbedManager | null = null;
|
||||
let client: HindsightClient | null = null;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let isInitialized = false;
|
||||
|
||||
// Global access for hooks (Moltbot loads hooks separately)
|
||||
if (typeof global !== 'undefined') {
|
||||
(global as any).__hindsightClient = {
|
||||
getClient: () => client,
|
||||
waitForReady: async () => {
|
||||
if (isInitialized) return;
|
||||
if (initPromise) await initPromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +20,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Default bank name
|
||||
const BANK_NAME = 'openclawd';
|
||||
const BANK_NAME = 'moltbot';
|
||||
|
||||
// Provider mapping: moltbot provider name -> hindsight provider name
|
||||
const PROVIDER_MAP: Record<string, string> = {
|
||||
@@ -114,14 +108,11 @@ function detectLLMConfig(api: MoltbotPluginAPI): {
|
||||
}
|
||||
|
||||
function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
|
||||
const config = api.config.plugins?.entries?.['hindsight-openclawd']?.config || {};
|
||||
const defaultMission = 'You are an AI assistant helping users across multiple communication channels (Telegram, Slack, Discord, etc.). Remember user preferences, instructions, and important context from conversations to provide personalized assistance.';
|
||||
|
||||
const config = api.config.plugins?.entries?.['hindsight-memory']?.config || {};
|
||||
return {
|
||||
bankMission: config.bankMission || defaultMission,
|
||||
bankMission: config.bankMission,
|
||||
embedPort: config.embedPort || 0,
|
||||
daemonIdleTimeout: config.daemonIdleTimeout !== undefined ? config.daemonIdleTimeout : 0,
|
||||
embedVersion: config.embedVersion || 'latest',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,57 +141,41 @@ export default function (api: MoltbotPluginAPI) {
|
||||
const port = pluginConfig.embedPort || Math.floor(Math.random() * 10000) + 10000;
|
||||
console.log(`[Hindsight] Port: ${port}`);
|
||||
|
||||
// Initialize in background (non-blocking)
|
||||
console.log('[Hindsight] Starting initialization in background...');
|
||||
initPromise = (async () => {
|
||||
try {
|
||||
// Initialize embed manager
|
||||
console.log('[Hindsight] Creating HindsightEmbedManager...');
|
||||
embedManager = new HindsightEmbedManager(
|
||||
port,
|
||||
llmConfig.provider,
|
||||
llmConfig.apiKey,
|
||||
llmConfig.model,
|
||||
pluginConfig.daemonIdleTimeout,
|
||||
pluginConfig.embedVersion
|
||||
);
|
||||
|
||||
// Start the embedded server
|
||||
console.log('[Hindsight] Starting embedded server...');
|
||||
await embedManager.start();
|
||||
|
||||
// Initialize client
|
||||
console.log('[Hindsight] Creating HindsightClient...');
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model, pluginConfig.embedVersion);
|
||||
|
||||
// Use openclawd bank
|
||||
console.log(`[Hindsight] Using bank: ${BANK_NAME}`);
|
||||
client.setBankId(BANK_NAME);
|
||||
|
||||
// Set bank mission
|
||||
if (pluginConfig.bankMission) {
|
||||
console.log(`[Hindsight] Setting bank mission...`);
|
||||
await client.setBankMission(pluginConfig.bankMission);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[Hindsight] ✓ Ready');
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Initialization error:', error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
// Don't await - let it initialize in background
|
||||
|
||||
// Register background service for cleanup
|
||||
// Register background service
|
||||
console.log('[Hindsight] Registering service...');
|
||||
api.registerService({
|
||||
id: 'hindsight-memory',
|
||||
async start() {
|
||||
// Wait for background init if still pending
|
||||
console.log('[Hindsight] Service start called - ensuring initialization complete...');
|
||||
if (initPromise) await initPromise;
|
||||
try {
|
||||
console.log('[Hindsight] Service starting...');
|
||||
|
||||
// Initialize embed manager
|
||||
console.log('[Hindsight] Creating HindsightEmbedManager...');
|
||||
embedManager = new HindsightEmbedManager(
|
||||
port,
|
||||
llmConfig.provider,
|
||||
llmConfig.apiKey,
|
||||
llmConfig.model,
|
||||
pluginConfig.daemonIdleTimeout
|
||||
);
|
||||
|
||||
// Start the embedded server
|
||||
console.log('[Hindsight] Starting embedded server...');
|
||||
await embedManager.start();
|
||||
|
||||
// Initialize client
|
||||
console.log('[Hindsight] Creating HindsightClient...');
|
||||
client = new HindsightClient(llmConfig.provider, llmConfig.apiKey, llmConfig.model);
|
||||
|
||||
// Use moltbot bank
|
||||
console.log(`[Hindsight] Using bank: ${BANK_NAME}`);
|
||||
client.setBankId(BANK_NAME);
|
||||
|
||||
console.log('[Hindsight] Service ready');
|
||||
} catch (error) {
|
||||
console.error('[Hindsight] Service start error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async stop() {
|
||||
@@ -213,7 +188,6 @@ export default function (api: MoltbotPluginAPI) {
|
||||
}
|
||||
|
||||
client = null;
|
||||
isInitialized = false;
|
||||
|
||||
console.log('[Hindsight] Service stopped');
|
||||
} catch (error) {
|
||||
@@ -255,18 +229,14 @@ export default function (api: MoltbotPluginAPI) {
|
||||
return; // Skip very short messages after extraction
|
||||
}
|
||||
|
||||
// Wait for client to be ready
|
||||
// Get client from global
|
||||
const clientGlobal = (global as any).__hindsightClient;
|
||||
if (!clientGlobal) {
|
||||
console.log('[Hindsight] Client global not available, skipping auto-recall');
|
||||
return;
|
||||
}
|
||||
|
||||
await clientGlobal.waitForReady();
|
||||
|
||||
const client = clientGlobal.getClient();
|
||||
if (!client) {
|
||||
console.log('[Hindsight] Client not initialized, skipping auto-recall');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -283,12 +253,21 @@ export default function (api: MoltbotPluginAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format memories as JSON with all fields from recall
|
||||
const memoriesJson = JSON.stringify(response.results, null, 2);
|
||||
// Format memories for injection
|
||||
const memories = response.results
|
||||
.map((result: any, idx: number) => {
|
||||
const score = result.score ? ` (relevance: ${result.score.toFixed(2)})` : '';
|
||||
return `${idx + 1}. ${result.content}${score}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const contextMessage = `<hindsight_memories>
|
||||
${memoriesJson}
|
||||
</hindsight_memories>`;
|
||||
const contextMessage = `<hindsight-context>
|
||||
You have access to long-term memory from previous conversations. Here are relevant memories:
|
||||
|
||||
${memories}
|
||||
|
||||
Use this context naturally when relevant to the conversation. Don't mention "memory" or "recall" unless specifically asked about past conversations.
|
||||
</hindsight-context>`;
|
||||
|
||||
console.log(`[Hindsight] Auto-recall: Injecting ${response.results.length} memories`);
|
||||
|
||||
@@ -310,15 +289,13 @@ ${memoriesJson}
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for client to be ready
|
||||
// Get client from global
|
||||
const clientGlobal = (global as any).__hindsightClient;
|
||||
if (!clientGlobal) {
|
||||
console.warn('[Hindsight] Client global not found, skipping retain');
|
||||
return;
|
||||
}
|
||||
|
||||
await clientGlobal.waitForReady();
|
||||
|
||||
const client = clientGlobal.getClient();
|
||||
if (!client) {
|
||||
console.warn('[Hindsight] Client not initialized, skipping retain');
|
||||
-1
@@ -31,7 +31,6 @@ export interface PluginConfig {
|
||||
bankMission?: string;
|
||||
embedPort?: number;
|
||||
daemonIdleTimeout?: number; // Seconds before daemon shuts down (0 = never)
|
||||
embedVersion?: string; // hindsight-embed version (default: "latest")
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
+1
-1
@@ -14,5 +14,5 @@
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
"exclude": ["node_modules", "dist", "skills"]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# Hindsight Memory Plugin for OpenClawd
|
||||
|
||||
Biomimetic long-term memory for [OpenClawd](https://openclawd.ai) using [Hindsight](https://vectorize.io/hindsight). Automatically captures conversations and intelligently recalls relevant context.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure your LLM provider
|
||||
export OPENAI_API_KEY="sk-your-key"
|
||||
clawdbot config set 'agents.defaults.models."openai/gpt-4o-mini"' '{}'
|
||||
|
||||
# 2. Install and enable the plugin
|
||||
clawdbot plugins install @vectorize-io/hindsight-openclawd
|
||||
|
||||
# 3. Start OpenClawd
|
||||
clawdbot gateway
|
||||
```
|
||||
|
||||
That's it! The plugin will automatically start capturing and recalling memories.
|
||||
|
||||
## Documentation
|
||||
|
||||
For full documentation, configuration options, troubleshooting, and development guide, see:
|
||||
|
||||
**[OpenClawd Integration Documentation](https://vectorize.io/hindsight/sdks/integrations/openclawd)**
|
||||
|
||||
## Links
|
||||
|
||||
- [Hindsight Documentation](https://vectorize.io/hindsight)
|
||||
- [OpenClawd Documentation](https://openclawd.ai)
|
||||
- [GitHub Repository](https://github.com/vectorize-io/hindsight)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"id": "hindsight-openclawd",
|
||||
"name": "Hindsight Memory",
|
||||
"kind": "memory",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"daemonIdleTimeout": {
|
||||
"type": "number",
|
||||
"description": "Seconds before daemon shuts down from inactivity (0 = never)",
|
||||
"default": 0
|
||||
},
|
||||
"embedPort": {
|
||||
"type": "number",
|
||||
"description": "Port for hindsight-embed server (auto-assigned if not specified)",
|
||||
"default": 0
|
||||
},
|
||||
"bankMission": {
|
||||
"type": "string",
|
||||
"description": "Custom mission/context for the memory bank",
|
||||
"default": "You are an AI assistant helping users across multiple communication channels (Telegram, Slack, Discord, etc.). Remember user preferences, instructions, and important context from conversations to provide personalized assistance."
|
||||
},
|
||||
"embedVersion": {
|
||||
"type": "string",
|
||||
"description": "hindsight-embed version to use (e.g. 'latest', '0.4.2', or empty for latest)",
|
||||
"default": "latest"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"uiHints": {
|
||||
"daemonIdleTimeout": {
|
||||
"label": "Daemon Idle Timeout",
|
||||
"placeholder": "0 (never timeout)"
|
||||
},
|
||||
"embedPort": {
|
||||
"label": "Embed Server Port",
|
||||
"placeholder": "0 (auto-assign)"
|
||||
},
|
||||
"bankMission": {
|
||||
"label": "Bank Mission",
|
||||
"placeholder": "Custom context for what this agent does..."
|
||||
},
|
||||
"embedVersion": {
|
||||
"label": "Hindsight Embed Version",
|
||||
"placeholder": "latest (or pin to specific version like 0.4.2)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -144,16 +144,6 @@ else
|
||||
print_warn "File $TYPESCRIPT_CLIENT_PKG not found, skipping"
|
||||
fi
|
||||
|
||||
# Update OpenClawd integration
|
||||
OPENCLAWD_PKG="hindsight-integrations/openclawd/package.json"
|
||||
if [ -f "$OPENCLAWD_PKG" ]; then
|
||||
print_info "Updating $OPENCLAWD_PKG"
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$OPENCLAWD_PKG"
|
||||
rm "${OPENCLAWD_PKG}.bak"
|
||||
else
|
||||
print_warn "File $OPENCLAWD_PKG not found, skipping"
|
||||
fi
|
||||
|
||||
# Update documentation version (creates new version or syncs to existing)
|
||||
print_info "Updating documentation for version $VERSION..."
|
||||
if [ -f "scripts/update-docs-version.sh" ]; then
|
||||
@@ -198,7 +188,6 @@ COMMIT_MSG="Release v$VERSION
|
||||
- TypeScript client: hindsight-clients/typescript
|
||||
- Rust CLI: hindsight-cli
|
||||
- Control Plane: hindsight-control-plane
|
||||
- OpenClawd integration: hindsight-integrations/openclawd
|
||||
- Helm chart"
|
||||
|
||||
# Add docs update note
|
||||
|
||||
Reference in New Issue
Block a user