Compare commits

..
Author SHA1 Message Date
Nicolò Boschi 7519ecac95 fixes 2026-01-30 10:57:30 +01:00
Nicolò Boschi ab0f8cec33 fix: deadlock in worker polling 2026-01-30 10:34:24 +01:00
Nicolò Boschi 19676d69a7 fix: deadlock in worker polling 2026-01-30 10:29:30 +01:00
36 changed files with 887 additions and 441 deletions
+13 -13
View File
@@ -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
+4 -4
View File
@@ -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:
+2 -1
View File
@@ -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})")
+10 -4
View File
@@ -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))),
)
+2 -1
View File
@@ -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,
)
+4 -8
View File
@@ -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
+205 -87
View File
@@ -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}"
+11 -11
View File
@@ -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,
)
+212 -15
View File
@@ -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
@@ -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)
+1 -3
View File
@@ -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
+38
View File
@@ -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;
@@ -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)"
@@ -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)}`;
}
}
@@ -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() });
@@ -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',
});
@@ -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');
@@ -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 {
@@ -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)"
}
}
}
-11
View File
@@ -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