hindsight-api 0.4.6__py3-none-any.whl → 0.4.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. hindsight_api/__init__.py +1 -1
  2. hindsight_api/alembic/versions/5a366d414dce_initial_schema.py +16 -2
  3. hindsight_api/api/http.py +83 -1
  4. hindsight_api/banner.py +3 -0
  5. hindsight_api/config.py +44 -6
  6. hindsight_api/daemon.py +18 -112
  7. hindsight_api/engine/llm_interface.py +146 -0
  8. hindsight_api/engine/llm_wrapper.py +304 -1327
  9. hindsight_api/engine/memory_engine.py +125 -41
  10. hindsight_api/engine/providers/__init__.py +14 -0
  11. hindsight_api/engine/providers/anthropic_llm.py +434 -0
  12. hindsight_api/engine/providers/claude_code_llm.py +352 -0
  13. hindsight_api/engine/providers/codex_llm.py +527 -0
  14. hindsight_api/engine/providers/gemini_llm.py +502 -0
  15. hindsight_api/engine/providers/mock_llm.py +234 -0
  16. hindsight_api/engine/providers/openai_compatible_llm.py +745 -0
  17. hindsight_api/engine/retain/fact_extraction.py +13 -9
  18. hindsight_api/engine/retain/fact_storage.py +5 -3
  19. hindsight_api/extensions/__init__.py +10 -0
  20. hindsight_api/extensions/builtin/tenant.py +36 -0
  21. hindsight_api/extensions/operation_validator.py +129 -0
  22. hindsight_api/main.py +6 -21
  23. hindsight_api/migrations.py +75 -0
  24. hindsight_api/worker/main.py +41 -11
  25. hindsight_api/worker/poller.py +26 -14
  26. {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/METADATA +2 -1
  27. {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/RECORD +29 -21
  28. {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/WHEEL +0 -0
  29. {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/entry_points.txt +0 -0
@@ -57,21 +57,25 @@ def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
57
57
  return None
58
58
 
59
59
 
60
- def _sanitize_text(text: str) -> str:
60
+ def _sanitize_text(text: str | None) -> str | None:
61
61
  """
62
- Sanitize text by removing invalid Unicode surrogate characters.
62
+ Sanitize text by removing characters that break downstream systems.
63
63
 
64
- Surrogate characters (U+D800 to U+DFFF) are used in UTF-16 encoding
65
- but cannot be encoded in UTF-8. They can appear in Python strings
66
- from improperly decoded data (e.g., from JavaScript or broken files).
64
+ Removes:
65
+ - Null bytes (\\x00): Invalid in PostgreSQL UTF-8 encoding
66
+ - Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
67
67
 
68
- This function removes unpaired surrogates to prevent UnicodeEncodeError
69
- when the text is sent to the LLM API.
68
+ Surrogate characters are used in UTF-16 encoding but cannot be encoded
69
+ in UTF-8. They can appear in Python strings from improperly decoded data
70
+ (e.g., from JavaScript or broken files). Null bytes commonly appear in
71
+ OCR output, PDF extraction, or copy-paste from binary sources.
70
72
  """
73
+ if text is None:
74
+ return None
71
75
  if not text:
72
76
  return text
73
- # Remove surrogate characters (U+D800 to U+DFFF) using regex
74
- # These are invalid in UTF-8 and cause encoding errors
77
+ # Remove null bytes and surrogate characters
78
+ text = text.replace("\x00", "")
75
79
  return re.sub(r"[\ud800-\udfff]", "", text)
76
80
 
77
81
 
@@ -8,6 +8,7 @@ import json
8
8
  import logging
9
9
 
10
10
  from ..memory_engine import fq_table
11
+ from .fact_extraction import _sanitize_text
11
12
  from .types import ProcessedFact
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -47,7 +48,7 @@ async def insert_facts_batch(
47
48
  tags_list = []
48
49
 
49
50
  for fact in facts:
50
- fact_texts.append(fact.fact_text)
51
+ fact_texts.append(_sanitize_text(fact.fact_text))
51
52
  # Convert embedding to string for asyncpg vector type
52
53
  embeddings.append(str(fact.embedding))
53
54
  # event_date: Use occurred_start if available, otherwise use mentioned_at
@@ -56,7 +57,7 @@ async def insert_facts_batch(
56
57
  occurred_starts.append(fact.occurred_start)
57
58
  occurred_ends.append(fact.occurred_end)
58
59
  mentioned_ats.append(fact.mentioned_at)
59
- contexts.append(fact.context)
60
+ contexts.append(_sanitize_text(fact.context))
60
61
  fact_types.append(fact.fact_type)
61
62
  # confidence_score is only for opinion facts
62
63
  confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
@@ -157,7 +158,8 @@ async def handle_document_tracking(
157
158
  """
158
159
  import hashlib
159
160
 
160
- # Calculate content hash
161
+ # Sanitize and calculate content hash
162
+ combined_content = _sanitize_text(combined_content) or ""
161
163
  content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
162
164
 
163
165
  # Always delete old document first if it exists (cascades to units and links)
@@ -24,6 +24,11 @@ from hindsight_api.extensions.operation_validator import (
24
24
  # Consolidation operation
25
25
  ConsolidateContext,
26
26
  ConsolidateResult,
27
+ # Mental Model operations
28
+ MentalModelGetContext,
29
+ MentalModelGetResult,
30
+ MentalModelRefreshContext,
31
+ MentalModelRefreshResult,
27
32
  # Core operations
28
33
  OperationValidationError,
29
34
  OperationValidatorExtension,
@@ -65,6 +70,11 @@ __all__ = [
65
70
  # Operation Validator - Consolidation
66
71
  "ConsolidateContext",
67
72
  "ConsolidateResult",
73
+ # Operation Validator - Mental Model
74
+ "MentalModelGetContext",
75
+ "MentalModelGetResult",
76
+ "MentalModelRefreshContext",
77
+ "MentalModelRefreshResult",
68
78
  # Tenant/Auth
69
79
  "ApiKeyTenantExtension",
70
80
  "AuthenticationError",
@@ -5,6 +5,42 @@ from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantC
5
5
  from hindsight_api.models import RequestContext
6
6
 
7
7
 
8
+ class DefaultTenantExtension(TenantExtension):
9
+ """
10
+ Default single-tenant extension with no authentication.
11
+
12
+ This is the default extension used when no tenant extension is configured.
13
+ It provides single-tenant behavior using the configured schema from
14
+ HINDSIGHT_API_DATABASE_SCHEMA (defaults to 'public').
15
+
16
+ Features:
17
+ - No authentication required (passes all requests)
18
+ - Uses configured schema from environment
19
+ - Perfect for single-tenant deployments without auth
20
+
21
+ Configuration:
22
+ HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
23
+
24
+ This is automatically enabled by default. To use custom authentication,
25
+ configure a different tenant extension:
26
+ HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
27
+ """
28
+
29
+ def __init__(self, config: dict[str, str]):
30
+ super().__init__(config)
31
+ # Cache the schema at initialization for consistency
32
+ # Support explicit schema override via config, otherwise use environment
33
+ self._schema = config.get("schema", get_config().database_schema)
34
+
35
+ async def authenticate(self, context: RequestContext) -> TenantContext:
36
+ """Return configured schema without any authentication."""
37
+ return TenantContext(schema_name=self._schema)
38
+
39
+ async def list_tenants(self) -> list[Tenant]:
40
+ """Return configured schema for single-tenant setup."""
41
+ return [Tenant(schema=self._schema)]
42
+
43
+
8
44
  class ApiKeyTenantExtension(TenantExtension):
9
45
  """
10
46
  Built-in tenant extension that validates API key against an environment variable.
@@ -196,6 +196,57 @@ class ConsolidateResult:
196
196
  error: str | None = None
197
197
 
198
198
 
199
+ # =============================================================================
200
+ # Mental Model Contexts
201
+ # =============================================================================
202
+
203
+
204
+ @dataclass
205
+ class MentalModelGetContext:
206
+ """Context for a mental model GET operation validation (pre-operation)."""
207
+
208
+ bank_id: str
209
+ mental_model_id: str
210
+ request_context: "RequestContext"
211
+
212
+
213
+ @dataclass
214
+ class MentalModelRefreshContext:
215
+ """Context for a mental model refresh/create operation validation (pre-operation)."""
216
+
217
+ bank_id: str
218
+ mental_model_id: str | None # None for create (not yet assigned)
219
+ request_context: "RequestContext"
220
+
221
+
222
+ @dataclass
223
+ class MentalModelGetResult:
224
+ """Result context for post-mental-model-GET hook."""
225
+
226
+ bank_id: str
227
+ mental_model_id: str
228
+ request_context: "RequestContext"
229
+ output_tokens: int # tokens in the returned content
230
+ success: bool = True
231
+ error: str | None = None
232
+
233
+
234
+ @dataclass
235
+ class MentalModelRefreshResult:
236
+ """Result context for post-mental-model-refresh hook."""
237
+
238
+ bank_id: str
239
+ mental_model_id: str
240
+ request_context: "RequestContext"
241
+ query_tokens: int # tokens in source_query
242
+ output_tokens: int # tokens in generated content
243
+ context_tokens: int # tokens in context (if any)
244
+ facts_used: int # facts referenced in based_on
245
+ mental_models_used: int # mental models referenced in based_on
246
+ success: bool = True
247
+ error: str | None = None
248
+
249
+
199
250
  class OperationValidatorExtension(Extension, ABC):
200
251
  """
201
252
  Validates and hooks into retain/recall/reflect/consolidate operations.
@@ -402,3 +453,81 @@ class OperationValidatorExtension(Extension, ABC):
402
453
  - error: Error message (if failed)
403
454
  """
404
455
  pass
456
+
457
+ # =========================================================================
458
+ # Mental Model - Pre-operation validation hook (optional - override to implement)
459
+ # =========================================================================
460
+
461
+ async def validate_mental_model_get(self, ctx: MentalModelGetContext) -> ValidationResult:
462
+ """
463
+ Validate a mental model GET operation before execution.
464
+
465
+ Override to implement custom validation logic for mental model retrieval.
466
+
467
+ Args:
468
+ ctx: Context containing:
469
+ - bank_id: Bank identifier
470
+ - mental_model_id: Mental model identifier
471
+ - request_context: Request context with auth info
472
+
473
+ Returns:
474
+ ValidationResult indicating whether the operation is allowed.
475
+ """
476
+ return ValidationResult.accept()
477
+
478
+ async def validate_mental_model_refresh(self, ctx: MentalModelRefreshContext) -> ValidationResult:
479
+ """
480
+ Validate a mental model refresh/create operation before execution.
481
+
482
+ Override to implement custom validation logic for mental model refresh.
483
+
484
+ Args:
485
+ ctx: Context containing:
486
+ - bank_id: Bank identifier
487
+ - mental_model_id: Mental model identifier (None for create)
488
+ - request_context: Request context with auth info
489
+
490
+ Returns:
491
+ ValidationResult indicating whether the operation is allowed.
492
+ """
493
+ return ValidationResult.accept()
494
+
495
+ # =========================================================================
496
+ # Mental Model - Post-operation hooks (optional - override to implement)
497
+ # =========================================================================
498
+
499
+ async def on_mental_model_get_complete(self, result: MentalModelGetResult) -> None:
500
+ """
501
+ Called after a mental model GET operation completes (success or failure).
502
+
503
+ Override to implement post-operation logic such as tracking or audit logging.
504
+
505
+ Args:
506
+ result: Result context containing:
507
+ - bank_id: Bank identifier
508
+ - mental_model_id: Mental model identifier
509
+ - output_tokens: Token count of the returned content
510
+ - success: Whether the operation succeeded
511
+ - error: Error message (if failed)
512
+ """
513
+ pass
514
+
515
+ async def on_mental_model_refresh_complete(self, result: MentalModelRefreshResult) -> None:
516
+ """
517
+ Called after a mental model refresh operation completes (success or failure).
518
+
519
+ Override to implement post-operation logic such as tracking or audit logging.
520
+
521
+ Args:
522
+ result: Result context containing:
523
+ - bank_id: Bank identifier
524
+ - mental_model_id: Mental model identifier
525
+ - query_tokens: Tokens in source_query
526
+ - output_tokens: Tokens in generated content
527
+ - context_tokens: Tokens in context
528
+ - facts_used: Number of facts referenced
529
+ - mental_models_used: Number of mental models referenced
530
+ - success: Whether the operation succeeded
531
+ - error: Error message (if failed)
532
+ """
533
+ pass
hindsight_api/main.py CHANGED
@@ -20,14 +20,13 @@ import warnings
20
20
 
21
21
  import uvicorn
22
22
 
23
- from . import MemoryEngine
23
+ from . import MemoryEngine, __version__
24
24
  from .api import create_app
25
25
  from .banner import print_banner
26
26
  from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
27
27
  from .daemon import (
28
28
  DEFAULT_DAEMON_PORT,
29
29
  DEFAULT_IDLE_TIMEOUT,
30
- DaemonLock,
31
30
  IdleTimeoutMiddleware,
32
31
  daemonize,
33
32
  )
@@ -136,30 +135,15 @@ def main():
136
135
 
137
136
  # Daemon mode handling
138
137
  if args.daemon:
139
- # Use fixed daemon port
140
- args.port = DEFAULT_DAEMON_PORT
138
+ # Use port from args (may be custom for profiles)
139
+ if args.port == config.port: # No custom port specified
140
+ args.port = DEFAULT_DAEMON_PORT
141
141
  args.host = "127.0.0.1" # Only bind to localhost for security
142
142
 
143
- # Check if another daemon is already running
144
- daemon_lock = DaemonLock()
145
- if not daemon_lock.acquire():
146
- print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
147
- sys.exit(1)
148
-
149
143
  # Fork into background
144
+ # No lockfile needed - port binding prevents duplicate daemons
150
145
  daemonize()
151
146
 
152
- # Re-acquire lock in child process
153
- daemon_lock = DaemonLock()
154
- if not daemon_lock.acquire():
155
- sys.exit(1)
156
-
157
- # Register cleanup to release lock
158
- def release_lock():
159
- daemon_lock.release()
160
-
161
- atexit.register(release_lock)
162
-
163
147
  # Print banner (not in daemon mode)
164
148
  if not args.daemon:
165
149
  print()
@@ -362,6 +346,7 @@ def main():
362
346
  embeddings_provider=config.embeddings_provider,
363
347
  reranker_provider=config.reranker_provider,
364
348
  mcp_enabled=config.mcp_enabled,
349
+ version=__version__,
365
350
  )
366
351
 
367
352
  # Start idle checker in daemon mode
@@ -165,6 +165,81 @@ def run_migrations(
165
165
  logger.debug("Migration advisory lock acquired")
166
166
 
167
167
  try:
168
+ # Ensure pgvector extension is installed globally BEFORE schema migrations
169
+ # This is critical: the extension must exist database-wide before any schema
170
+ # migrations run, otherwise custom schemas won't have access to vector types
171
+ logger.debug("Checking pgvector extension availability...")
172
+
173
+ # First, check if extension already exists
174
+ ext_check = conn.execute(
175
+ text(
176
+ "SELECT extname, nspname FROM pg_extension e "
177
+ "JOIN pg_namespace n ON e.extnamespace = n.oid "
178
+ "WHERE extname = 'vector'"
179
+ )
180
+ ).fetchone()
181
+
182
+ if ext_check:
183
+ # Extension exists - check if in correct schema
184
+ ext_schema = ext_check[1]
185
+ if ext_schema == "public":
186
+ logger.info("pgvector extension found in public schema - ready to use")
187
+ else:
188
+ # Extension in wrong schema - try to fix if we have permissions
189
+ logger.warning(
190
+ f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
191
+ f"Attempting to relocate..."
192
+ )
193
+ try:
194
+ conn.execute(text("DROP EXTENSION vector CASCADE"))
195
+ conn.execute(text("SET search_path TO public"))
196
+ conn.execute(text("CREATE EXTENSION vector"))
197
+ conn.commit()
198
+ logger.info("pgvector extension relocated to public schema")
199
+ except Exception as e:
200
+ # Failed to relocate - log but don't fail if extension exists somewhere
201
+ logger.warning(
202
+ f"Could not relocate pgvector extension to public schema: {e}. "
203
+ f"Continuing with extension in '{ext_schema}' schema."
204
+ )
205
+ conn.rollback()
206
+ else:
207
+ # Extension doesn't exist - try to install
208
+ logger.info("pgvector extension not found, attempting to install...")
209
+ try:
210
+ conn.execute(text("SET search_path TO public"))
211
+ conn.execute(text("CREATE EXTENSION vector"))
212
+ conn.commit()
213
+ logger.info("pgvector extension installed in public schema")
214
+ except Exception as e:
215
+ # Installation failed - this is only fatal if extension truly doesn't exist
216
+ # Check one more time in case another process installed it
217
+ conn.rollback()
218
+ ext_recheck = conn.execute(
219
+ text(
220
+ "SELECT nspname FROM pg_extension e "
221
+ "JOIN pg_namespace n ON e.extnamespace = n.oid "
222
+ "WHERE extname = 'vector'"
223
+ )
224
+ ).fetchone()
225
+
226
+ if ext_recheck:
227
+ logger.warning(
228
+ f"Could not install pgvector extension (permission denied?), "
229
+ f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
230
+ )
231
+ else:
232
+ # Extension truly doesn't exist and we can't install it
233
+ logger.error(
234
+ f"pgvector extension is not installed and cannot be installed: {e}. "
235
+ f"Please ensure pgvector is installed by a database administrator. "
236
+ f"See: https://github.com/pgvector/pgvector#installation"
237
+ )
238
+ raise RuntimeError(
239
+ "pgvector extension is required but not installed. "
240
+ "Please install it with: CREATE EXTENSION vector;"
241
+ ) from e
242
+
168
243
  # Run migrations while holding the lock
169
244
  _run_migrations_internal(database_url, script_location, schema=schema)
170
245
  finally:
@@ -176,7 +176,7 @@ def main():
176
176
  nonlocal memory, poller
177
177
  import uvicorn
178
178
 
179
- from ..extensions import TenantExtension, load_extension
179
+ from ..extensions import OperationValidatorExtension, TenantExtension, load_extension
180
180
 
181
181
  # Load tenant extension BEFORE creating MemoryEngine so it can
182
182
  # set correct schema context during task execution. Without this,
@@ -184,6 +184,12 @@ def main():
184
184
  # causing worker writes to land in the wrong schema.
185
185
  tenant_extension = load_extension("TENANT", TenantExtension)
186
186
 
187
+ # Load operation validator so workers can record usage metering
188
+ # for async operations (e.g. refresh_mental_model after consolidation)
189
+ operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
190
+ if operation_validator:
191
+ logger.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
192
+
187
193
  # Initialize MemoryEngine
188
194
  # Workers use SyncTaskBackend because they execute tasks directly,
189
195
  # they don't need to store tasks (they poll from DB)
@@ -191,6 +197,7 @@ def main():
191
197
  run_migrations=False, # Workers don't run migrations
192
198
  task_backend=SyncTaskBackend(),
193
199
  tenant_extension=tenant_extension,
200
+ operation_validator=operation_validator,
194
201
  )
195
202
 
196
203
  await memory.initialize()
@@ -200,15 +207,20 @@ def main():
200
207
  if tenant_extension:
201
208
  print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
202
209
  else:
203
- print("No tenant extension configured, using public schema only")
210
+ print(f"No tenant extension configured, using schema: {config.database_schema}")
204
211
 
205
212
  # Create a single poller that handles all schemas dynamically
213
+ # Convert default schema to None for SQL compatibility (no schema prefix)
214
+ from hindsight_api.config import DEFAULT_DATABASE_SCHEMA
215
+
216
+ schema = None if config.database_schema == DEFAULT_DATABASE_SCHEMA else config.database_schema
206
217
  poller = WorkerPoller(
207
218
  pool=memory._pool,
208
219
  worker_id=args.worker_id,
209
220
  executor=memory.execute_task,
210
221
  poll_interval_ms=args.poll_interval,
211
222
  max_retries=args.max_retries,
223
+ schema=schema,
212
224
  tenant_extension=tenant_extension,
213
225
  max_slots=config.worker_max_slots,
214
226
  consolidation_max_slots=config.worker_consolidation_max_slots,
@@ -217,15 +229,30 @@ def main():
217
229
  # Create the HTTP app for metrics/health
218
230
  app = create_worker_app(poller, memory)
219
231
 
220
- # Setup signal handlers for graceful shutdown
232
+ # Setup signal handlers for graceful shutdown using asyncio
221
233
  shutdown_requested = asyncio.Event()
222
-
223
- def signal_handler(signum, frame):
224
- print(f"\nReceived signal {signum}, initiating graceful shutdown...")
225
- shutdown_requested.set()
226
-
227
- signal.signal(signal.SIGINT, signal_handler)
228
- signal.signal(signal.SIGTERM, signal_handler)
234
+ force_exit = False
235
+
236
+ loop = asyncio.get_event_loop()
237
+
238
+ def signal_handler():
239
+ nonlocal force_exit
240
+ if shutdown_requested.is_set():
241
+ # Second signal = force exit
242
+ print("\nReceived second signal, forcing immediate exit...")
243
+ force_exit = True
244
+ # Restore default handler so third signal kills process
245
+ loop.remove_signal_handler(signal.SIGINT)
246
+ loop.remove_signal_handler(signal.SIGTERM)
247
+ sys.exit(1)
248
+ else:
249
+ print("\nReceived shutdown signal, initiating graceful shutdown...")
250
+ print("(Press Ctrl+C again to force immediate exit)")
251
+ shutdown_requested.set()
252
+
253
+ # Use asyncio's signal handlers which work properly with the event loop
254
+ loop.add_signal_handler(signal.SIGINT, signal_handler)
255
+ loop.add_signal_handler(signal.SIGTERM, signal_handler)
229
256
 
230
257
  # Create uvicorn config and server
231
258
  uvicorn_config = uvicorn.Config(
@@ -244,7 +271,10 @@ def main():
244
271
  print(f"Worker started. Metrics available at http://{args.http_host}:{args.http_port}/metrics")
245
272
 
246
273
  # Wait for shutdown signal
247
- await shutdown_requested.wait()
274
+ try:
275
+ await shutdown_requested.wait()
276
+ except KeyboardInterrupt:
277
+ print("\nReceived interrupt, initiating graceful shutdown...")
248
278
 
249
279
  # Graceful shutdown
250
280
  print("Shutting down HTTP server...")
@@ -72,9 +72,9 @@ class WorkerPoller:
72
72
  executor: Async function to execute tasks (typically MemoryEngine.execute_task)
73
73
  poll_interval_ms: Interval between polls when no tasks found (milliseconds)
74
74
  max_retries: Maximum retry attempts before marking task as failed
75
- schema: Database schema for single-tenant support (ignored if tenant_extension is set)
76
- tenant_extension: Extension for dynamic multi-tenant discovery. If set, list_tenants()
77
- is called on each poll cycle to discover schemas dynamically.
75
+ schema: Database schema for single-tenant support (deprecated, use tenant_extension)
76
+ tenant_extension: Extension for dynamic multi-tenant discovery. If None, creates a
77
+ DefaultTenantExtension with the configured schema.
78
78
  max_slots: Maximum concurrent tasks per worker
79
79
  consolidation_max_slots: Maximum concurrent consolidation tasks per worker
80
80
  """
@@ -84,6 +84,13 @@ class WorkerPoller:
84
84
  self._poll_interval_ms = poll_interval_ms
85
85
  self._max_retries = max_retries
86
86
  self._schema = schema
87
+ # Always set tenant extension (use DefaultTenantExtension if none provided)
88
+ if tenant_extension is None:
89
+ from ..extensions.builtin.tenant import DefaultTenantExtension
90
+
91
+ # Pass schema parameter to DefaultTenantExtension if explicitly provided
92
+ config = {"schema": schema} if schema else {}
93
+ tenant_extension = DefaultTenantExtension(config=config)
87
94
  self._tenant_extension = tenant_extension
88
95
  self._max_slots = max_slots
89
96
  self._consolidation_max_slots = consolidation_max_slots
@@ -99,13 +106,12 @@ class WorkerPoller:
99
106
  self._in_flight_by_type: dict[str, int] = {}
100
107
 
101
108
  async def _get_schemas(self) -> list[str | None]:
102
- """Get list of schemas to poll. Returns [None] for public schema."""
103
- if self._tenant_extension is not None:
104
- tenants = await self._tenant_extension.list_tenants()
105
- # Convert "public" to None for SQL compatibility, keep others as-is
106
- return [t.schema if t.schema != "public" else None for t in tenants]
107
- # Single schema mode
108
- return [self._schema]
109
+ """Get list of schemas to poll. Returns [None] for default schema (no prefix)."""
110
+ from ..config import DEFAULT_DATABASE_SCHEMA
111
+
112
+ tenants = await self._tenant_extension.list_tenants()
113
+ # Convert default schema to None for SQL compatibility (no prefix), keep others as-is
114
+ return [t.schema if t.schema != DEFAULT_DATABASE_SCHEMA else None for t in tenants]
109
115
 
110
116
  async def _get_available_slots(self) -> tuple[int, int]:
111
117
  """
@@ -194,7 +200,9 @@ class WorkerPoller:
194
200
  try:
195
201
  return await self._claim_batch_for_schema_inner(schema, limit, consolidation_limit)
196
202
  except Exception as e:
197
- logger.warning(f"Worker {self._worker_id} failed to claim tasks for schema {schema or 'public'}: {e}")
203
+ # Format schema for logging: custom schemas in quotes, None as-is
204
+ schema_display = f'"{schema}"' if schema else str(schema)
205
+ logger.warning(f"Worker {self._worker_id} failed to claim tasks for schema {schema_display}: {e}")
198
206
  return []
199
207
 
200
208
  async def _claim_batch_for_schema_inner(
@@ -418,7 +426,9 @@ class WorkerPoller:
418
426
  count = int(result.split()[-1]) if result else 0
419
427
  total_count += count
420
428
  except Exception as e:
421
- logger.warning(f"Worker {self._worker_id} failed to recover tasks for schema {schema or 'public'}: {e}")
429
+ # Format schema for logging: custom schemas in quotes, None as-is
430
+ schema_display = f'"{schema}"' if schema else str(schema)
431
+ logger.warning(f"Worker {self._worker_id} failed to recover tasks for schema {schema_display}: {e}")
422
432
 
423
433
  if total_count > 0:
424
434
  logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
@@ -457,7 +467,8 @@ class WorkerPoller:
457
467
  consolidation_count += 1
458
468
 
459
469
  types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
460
- schemas_str = ", ".join(s or "public" for s in schemas_seen)
470
+ # Display None as "default" in logs
471
+ schemas_str = ", ".join(s if s else "default" for s in schemas_seen)
461
472
  logger.info(
462
473
  f"Worker {self._worker_id} claimed {len(tasks)} tasks "
463
474
  f"({consolidation_count} consolidation): {types_str} (schemas: {schemas_str})"
@@ -591,7 +602,8 @@ class WorkerPoller:
591
602
  other_workers.append(f"{wid}:{cnt}")
592
603
  others_str = ", ".join(other_workers) if other_workers else "none"
593
604
 
594
- schemas_str = ", ".join(s or "public" for s in schemas)
605
+ # Display None as "default" in logs
606
+ schemas_str = ", ".join(s if s else "default" for s in schemas)
595
607
  logger.info(
596
608
  f"[WORKER_STATS] worker={self._worker_id} "
597
609
  f"slots={in_flight}/{self._max_slots} (consolidation={consolidation_count}/{self._consolidation_max_slots}) | "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hindsight-api
3
- Version: 0.4.6
3
+ Version: 0.4.8
4
4
  Summary: Hindsight: Agent Memory That Works Like Human Memory
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: aiohttp>=3.13.3
@@ -8,6 +8,7 @@ Requires-Dist: alembic>=1.17.1
8
8
  Requires-Dist: anthropic>=0.40.0
9
9
  Requires-Dist: asyncpg>=0.29.0
10
10
  Requires-Dist: authlib>=1.6.6
11
+ Requires-Dist: claude-agent-sdk>=0.1.27
11
12
  Requires-Dist: cohere>=5.0.0
12
13
  Requires-Dist: dateparser>=1.2.2
13
14
  Requires-Dist: fastapi[standard]>=0.120.3