diff --git a/README.md b/README.md index 4844467..8c016ee 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ CLI and data enrichment utilities for the [Parallel API](https://docs.parallel.a - **Content Extraction** - Extract clean markdown from any URL - **Data Enrichment** - Enrich CSV, JSON, DuckDB, and BigQuery data with AI - **Follow-up Context** - Chain research and enrichment tasks using `--previous-interaction-id` +- **Research Memory** - Recall, scope, evict, or clear saved Task, Monitor, and FindAll work - **AI-Assisted Planning** - Use natural language to define what data you want - **Multiple Integrations** - Polars, DuckDB, Snowflake, BigQuery, Spark @@ -97,14 +98,18 @@ parallel-cli │ ├── extend # Request additional candidates for a run │ ├── schema # Get the schema for a FindAll run │ └── cancel # Cancel a running FindAll -└── monitor # Continuous web change tracking - ├── create # Create a new web monitor (event_stream or snapshot) - ├── list # List monitors (cursor paginated) - ├── get # Get monitor details - ├── update # Update frequency, webhook, metadata - ├── cancel # Cancel a monitor (irreversible) - ├── events # List events for a monitor - └── trigger # Trigger an immediate one-off run +├── monitor # Continuous web change tracking +│ ├── create # Create a new web monitor (event_stream or snapshot) +│ ├── list # List monitors (cursor paginated) +│ ├── get # Get monitor details +│ ├── update # Update frequency, webhook, metadata +│ ├── cancel # Cancel a monitor (irreversible) +│ ├── events # List events for a monitor +│ └── trigger # Trigger an immediate one-off run +└── memory # Saved Task, Monitor, and FindAll entries + ├── retrieve # Search Memory or list recent entries + ├── evict # Remove one entry from Memory + └── clear # Permanently clear selected Memory ``` ## Quick Start @@ -189,6 +194,43 @@ parallel-cli enrich run \ parallel-cli enrich deploy --system bigquery --project my-gcp-project ``` +### 6. Recall Memory Entries + +Memory is an explicit recall layer for completed Task, Monitor, and FindAll work. +Saving is asynchronous, so a newly completed run may take a short time to appear. + +```bash +# Retrieve prior work by relevance +parallel-cli memory retrieve --query "serverless inference vendors" --json + +# List the most recent saved work +parallel-cli memory retrieve --query "" --json + +# Keep application or workspace research in an isolated memory scope +parallel-cli research run "Compare inference vendors" \ + --memory-scope-key workspace_acme +parallel-cli findall run "Serverless inference vendors" \ + --memory-scope-key workspace_acme +parallel-cli monitor create "Track vendor pricing changes" \ + --memory-scope-key workspace_acme + +# Retrieve from the same memory scope +parallel-cli memory retrieve \ + --query "inference vendors" \ + --scope-key workspace_acme \ + --json + +# Remove one Memory entry without deleting the Task itself +parallel-cli memory evict --kind task --id trun_abc123 --json + +# Permanently clear a specific memory scope (underlying resources remain) +parallel-cli memory clear --scope-key workspace_acme --confirm-clear --json +``` + +Omit `--scope-key` / `--memory-scope-key` to use personal Memory when the +credential and account are eligible. Application credentials require a stable +scope key containing only letters, digits, underscores, or hyphens. + ## Non-Interactive Mode (for AI Agents & Scripts) All commands support `--json` output and can be fully controlled via CLI arguments. @@ -262,6 +304,9 @@ parallel-cli findall entity-search "AI startups in healthcare" -t companies -n 2 # Monitor: track web changes parallel-cli monitor create "Track Tesla SEC filings" --frequency 1d --json +# Recall prior Memory entries +parallel-cli memory retrieve --query "Tesla filings" --json + # Plan without prompts (provide all args) parallel-cli enrich plan -o config.yaml \ --source-type csv \ @@ -409,6 +454,36 @@ monitors = list_monitors() details = get_monitor(monitor["monitor_id"]) ``` +### Memory + +Retrieve and manage Memory entries: + +```python +from parallel_web_tools import clear_memory, evict_memory, retrieve_memory + +# Relevant prior work across Task, Monitor, and FindAll +memories = retrieve_memory("serverless inference vendors", limit=10) + +# Retrieve from an isolated memory scope +scoped = retrieve_memory( + "inference vendors", + memory_scope_key="workspace_acme", +) + +# Remove one run from Memory; the underlying Task run remains available +evict_memory( + "task", + "trun_abc123", + memory_scope_key="workspace_acme", +) + +# Clear the selected memory; underlying runs remain available +clear_memory(memory_scope_key="workspace_acme") +``` + +These functions use the generated `client.beta.memory` resource provided by +`parallel-web` 1.2.0 or later. + ## YAML Configuration Format ```yaml diff --git a/parallel-web-tools.spec b/parallel-web-tools.spec index 5c7d075..b7652a2 100644 --- a/parallel-web-tools.spec +++ b/parallel-web-tools.spec @@ -33,6 +33,7 @@ a = Analysis( 'parallel_web_tools.core.runner', 'parallel_web_tools.core.schema', 'parallel_web_tools.core.research', + 'parallel_web_tools.core.memory', 'parallel_web_tools.core.result', # CLI (standalone mode - no planner) 'parallel_web_tools.cli', diff --git a/parallel_web_tools/__init__.py b/parallel_web_tools/__init__.py index 102c888..6e358ea 100644 --- a/parallel_web_tools/__init__.py +++ b/parallel_web_tools/__init__.py @@ -3,17 +3,23 @@ # Re-export everything from core for convenience from parallel_web_tools.core import ( AVAILABLE_PROCESSORS, + MEMORY_KINDS, Column, DeviceCodeInfo, InputSchema, + MemoryApiError, + MemoryInputError, + MemoryKind, ParseError, ProcessorType, SourceType, cancel_monitor, + clear_memory, create_monitor, enrich_batch, enrich_single, entity_search_findall, + evict_memory, get_api_key, get_async_client, get_auth_status, @@ -27,6 +33,7 @@ parse_schema, poll_device_token, request_device_code, + retrieve_memory, run_enrichment, run_enrichment_from_dict, run_findall, @@ -62,6 +69,14 @@ "enrich_batch", "enrich_single", "run_tasks", + # Memory + "MEMORY_KINDS", + "MemoryApiError", + "MemoryInputError", + "MemoryKind", + "clear_memory", + "evict_memory", + "retrieve_memory", # Runner "run_enrichment", "run_enrichment_from_dict", diff --git a/parallel_web_tools/cli/commands.py b/parallel_web_tools/cli/commands.py index 2467ac2..c5dd701 100644 --- a/parallel_web_tools/cli/commands.py +++ b/parallel_web_tools/cli/commands.py @@ -8,7 +8,7 @@ import tempfile import time from pathlib import Path -from typing import Any, NoReturn +from typing import Any, NoReturn, cast import click import httpx @@ -21,17 +21,22 @@ AVAILABLE_PROCESSORS, FINDALL_GENERATORS, JSON_SCHEMA_TYPE_MAP, + MEMORY_KINDS, MONITOR_PROCESSORS, MONITOR_TYPES, RESEARCH_PROCESSORS, + MemoryInputError, + MemoryKind, ReauthenticationRequired, cancel_findall_run, cancel_monitor, + clear_memory, create_findall_run, create_monitor, create_research_task, enrich_findall, entity_search_findall, + evict_memory, extend_findall, get_api_key, get_auth_status, @@ -50,11 +55,13 @@ poll_findall, poll_research, poll_task_group, + retrieve_memory, run_enrichment_from_dict, run_findall, run_research, trigger_monitor, update_monitor, + validate_memory_scope_key, ) # Standalone CLI (PyInstaller) has limited features to reduce bundle size @@ -268,6 +275,23 @@ def parse_comma_separated(values: tuple[str, ...]) -> list[str]: return result +class MemoryScopeKeyType(click.ParamType): + """Click validator for personal/application Memory scope keys.""" + + name = "memory scope key" + + def convert(self, value, param, ctx): + if value is None: + return None + try: + return validate_memory_scope_key(value) + except MemoryInputError as exc: + self.fail(str(exc), param, ctx) + + +MEMORY_SCOPE_KEY = MemoryScopeKeyType() + + def write_json_output(data: dict[str, Any], output_file: str | None, output_json: bool) -> None: """Write output data to file and/or stdout as JSON. @@ -496,7 +520,7 @@ def _auto_update(): @click.group(cls=ParallelCLI) @click.version_option(version=__version__, prog_name="parallel-cli") def main(): - """Parallel CLI - Search, research, enrich, and monitor the web.""" + """Parallel CLI - Search, research, enrich, monitor, and recall the web.""" pass @@ -876,6 +900,190 @@ def parse_bool(v: str) -> bool: main.add_command(create_skills_group(console, _handle_error, EXIT_BAD_INPUT, EXIT_API_ERROR)) +# ============================================================================= +# Memory Commands +# ============================================================================= + + +@main.group() +def memory(): + """Search and manage saved Task, Monitor, and FindAll entries.""" + pass + + +def _render_memory_results(result: dict[str, Any]) -> None: + """Render bounded Memory previews for human-readable CLI output.""" + results = result.get("results", []) + if not results: + console.print("[yellow]No Memory entries found.[/yellow]") + return + + noun = "entry" if len(results) == 1 else "entries" + console.print(f"[bold green]Found {len(results)} {noun} in Memory.[/bold green]\n") + for index, item in enumerate(results, 1): + kind = item.get("kind", "unknown") + source_id = item.get("id", "unknown") + updated_at = item.get("updated_at", "unknown") + console.print(f"[bold cyan]{index}. {kind} · {source_id}[/bold cyan]") + console.print(f" [dim]Updated: {updated_at}[/dim]") + + input_excerpt = item.get("input_excerpt") + if input_excerpt: + console.print(" [bold]Input[/bold]") + console.print(f" {input_excerpt}", markup=False) + + if kind == "task" and item.get("output_excerpt"): + console.print(" [bold]Output[/bold]") + console.print(f" {item['output_excerpt']}", markup=False) + elif kind == "findall": + console.print(f" [bold]Matched entities:[/bold] {item.get('matched_count', 0)}") + elif kind == "monitor": + console.print(f" [bold]Status:[/bold] {item.get('status', 'unknown')}") + for event in item.get("matched_events", []): + console.print( + f" [dim]Event {event.get('event_id', 'unknown')} · {event.get('detected_at', 'unknown')}[/dim]" + ) + if event.get("excerpt"): + console.print(f" {event['excerpt']}", markup=False) + console.print() + + +@memory.command(name="retrieve") +@click.argument("query_arg", required=False, metavar="[QUERY]") +@click.option("--query", "query_option", help="Semantic query. Omit or pass an empty string for recent memories.") +@click.option("--limit", type=click.IntRange(1, 25), default=10, show_default=True) +@click.option("--kind", type=click.Choice(list(MEMORY_KINDS)), help="Filter by entry kind.") +@click.option("--since", help="RFC 3339 lower timestamp bound, including a timezone.") +@click.option( + "--scope-key", + "--memory-scope-key", + "memory_scope_key", + type=MEMORY_SCOPE_KEY, + help="Optional key identifying the memory scope to use. Omit to use personal memory, if available.", +) +@click.option("-o", "--output", "output_file", type=click.Path(), help="Save results to a JSON file.") +@click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout.") +def memory_retrieve( + query_arg: str | None, + query_option: str | None, + limit: int, + kind: str | None, + since: str | None, + memory_scope_key: str | None, + output_file: str | None, + output_json: bool, +): + """Search Memory or list recent entries. + + Provide QUERY to rank entries by relevance. Omit it to return the most + recent entries. + """ + if query_arg is not None and query_option is not None: + raise click.UsageError("Provide the query either as QUERY or with --query, not both.") + query = query_option if query_option is not None else (query_arg or "") + + try: + result = retrieve_memory( + query=query, + limit=limit, + kind=cast(MemoryKind | None, kind), + since=since, + memory_scope_key=memory_scope_key, + source="cli", + ) + except MemoryInputError as e: + _handle_error(e, output_json=output_json, exit_code=EXIT_BAD_INPUT, prefix="Invalid Memory request") + return + except Exception as e: + _handle_error(e, output_json=output_json, prefix="Memory API error") + return + + write_json_output(result, output_file, output_json) + if not output_json: + _render_memory_results(result) + + +@memory.command(name="evict") +@click.option("--kind", type=click.Choice(list(MEMORY_KINDS)), required=True, help="Entry kind.") +@click.option("--id", "source_id", required=True, help="Exact entry ID.") +@click.option( + "--scope-key", + "--memory-scope-key", + "memory_scope_key", + type=MEMORY_SCOPE_KEY, + help="Optional key identifying the memory scope to use. Omit to use personal memory, if available.", +) +@click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout.") +def memory_evict(kind: str, source_id: str, memory_scope_key: str | None, output_json: bool): + """Remove one entry from Memory. + + The underlying Task, Monitor, or FindAll resource is not deleted. + """ + try: + result = evict_memory( + kind=cast(MemoryKind, kind), + source_id=source_id, + memory_scope_key=memory_scope_key, + source="cli", + ) + except MemoryInputError as e: + _handle_error(e, output_json=output_json, exit_code=EXIT_BAD_INPUT, prefix="Invalid Memory request") + return + except Exception as e: + _handle_error(e, output_json=output_json, prefix="Memory API error") + return + + if output_json: + print(json.dumps(result, indent=2)) + return + + memory_label = f"Memory scope {memory_scope_key!r}" if memory_scope_key else "personal Memory" + console.print(f"[bold green]Removed {kind} entry {source_id} from {memory_label}.[/bold green]") + console.print("[dim]The underlying Task, Monitor, or FindAll resource was not deleted.[/dim]") + + +@memory.command(name="clear") +@click.option( + "--scope-key", + "--memory-scope-key", + "memory_scope_key", + type=MEMORY_SCOPE_KEY, + help="Optional key identifying the memory scope to use. Omit to use personal memory, if available.", +) +@click.option( + "--confirm-clear", + is_flag=True, + help="Confirm permanent clearing of the selected Memory.", +) +@click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout.") +def memory_clear(memory_scope_key: str | None, confirm_clear: bool, output_json: bool): + """Remove all entries from selected Memory. + + The underlying Task, Monitor, and FindAll resources are not deleted. + """ + if not confirm_clear: + error = MemoryInputError("clear requires --confirm-clear") + _handle_error(error, output_json=output_json, exit_code=EXIT_BAD_INPUT, prefix="Invalid Memory request") + return + + try: + result = clear_memory(memory_scope_key=memory_scope_key, source="cli") + except MemoryInputError as e: + _handle_error(e, output_json=output_json, exit_code=EXIT_BAD_INPUT, prefix="Invalid Memory request") + return + except Exception as e: + _handle_error(e, output_json=output_json, prefix="Memory API error") + return + + if output_json: + print(json.dumps(result, indent=2)) + return + + memory_label = f"Memory scope {memory_scope_key!r}" if memory_scope_key else "personal Memory" + console.print(f"[bold green]Cleared {memory_label}.[/bold green]") + console.print("[dim]Underlying Task, Monitor, and FindAll resources were not deleted.[/dim]") + + # ============================================================================= # Search Command # ============================================================================= @@ -1960,6 +2168,12 @@ def research(): "--previous-interaction-id", help="Interaction ID from a previous task to reuse as context", ) +@click.option( + "--memory-scope-key", + "--scope-key", + type=MEMORY_SCOPE_KEY, + help="Optional key identifying the memory scope to use. Omit to use personal memory, if available.", +) def research_run( query: str | None, input_file: str | None, @@ -1974,6 +2188,7 @@ def research_run( force: bool, output_json: bool, previous_interaction_id: str | None, + memory_scope_key: str | None, ): """Run deep research on a question or topic. @@ -2004,6 +2219,8 @@ def research_run( echo "My research question" | parallel-cli research run - --json parallel-cli research run "What are the implications?" \\ --previous-interaction-id trun_abc123 + parallel-cli research run "Research for this workspace" \\ + --memory-scope-key workspace_acme """ output_schema = "text" if use_text else "auto" @@ -2041,6 +2258,8 @@ def research_run( "output_paths": planned_paths, "force": force, } + if memory_scope_key is not None: + dry_run_data["memory_scope_key"] = memory_scope_key if output_json: print(json.dumps(dry_run_data, indent=2)) else: @@ -2069,6 +2288,7 @@ def research_run( previous_interaction_id=previous_interaction_id, output_schema=output_schema, text_description=text_description, + memory_scope_key=memory_scope_key, ) run_id_box.append(result["run_id"]) @@ -2121,6 +2341,7 @@ def on_status(status: str, run_id: str): previous_interaction_id=previous_interaction_id, output_schema=output_schema, text_description=text_description, + memory_scope_key=memory_scope_key, ) _save_and_display_research(result, output_base, output_json, force=force) @@ -2540,6 +2761,12 @@ def findall(): is_flag=True, help="Ingest schema via API to preview entity type and conditions, but don't create the run", ) +@click.option( + "--memory-scope-key", + "--scope-key", + type=MEMORY_SCOPE_KEY, + help="Optional key identifying the memory scope to use. Omit to use personal memory, if available.", +) @click.option("-o", "--output", "output_file", type=click.Path(), help="Save results to JSON file") @click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout") def findall_run( @@ -2552,6 +2779,7 @@ def findall_run( poll_interval: int, no_wait: bool, dry_run: bool, + memory_scope_key: str | None, output_file: str | None, output_json: bool, ): @@ -2593,6 +2821,8 @@ def findall_run( "match_conditions": schema.get("match_conditions", []), "enrichments": schema.get("enrichments", []), } + if memory_scope_key is not None: + dry_run_data["memory_scope_key"] = memory_scope_key if output_json: print(json.dumps(dry_run_data, indent=2, default=str)) @@ -2639,6 +2869,7 @@ def findall_run( exclude_list=exclude_list, metadata=metadata, source="cli", + memory_scope_key=memory_scope_key, ) if output_json: @@ -2694,6 +2925,7 @@ def on_status(status: str, findall_id: str, metrics: dict): poll_interval=poll_interval, on_status=on_status, source="cli", + memory_scope_key=memory_scope_key, ) _output_findall_result(result, output_file, output_json) @@ -3205,6 +3437,12 @@ def monitor(): is_flag=True, help="event_stream only: include a sample of historical events on first run.", ) +@click.option( + "--memory-scope-key", + "--scope-key", + type=MEMORY_SCOPE_KEY, + help="Optional key identifying the memory scope to use. Omit to use personal memory, if available.", +) @click.option("-o", "--output", "output_file", type=click.Path(), help="Save result to JSON file") @click.option("--json", "output_json", is_flag=True, help="Output JSON to stdout") def monitor_create( @@ -3217,6 +3455,7 @@ def monitor_create( metadata_json: str | None, output_schema_json: str | None, include_backfill: bool, + memory_scope_key: str | None, output_file: str | None, output_json: bool, ): @@ -3272,6 +3511,7 @@ def monitor_create( include_backfill=include_backfill or None, processor=processor, source="cli", + memory_scope_key=memory_scope_key, ) write_json_output(result, output_file, output_json) diff --git a/parallel_web_tools/core/__init__.py b/parallel_web_tools/core/__init__.py index 1fefa8b..cacfe78 100644 --- a/parallel_web_tools/core/__init__.py +++ b/parallel_web_tools/core/__init__.py @@ -39,6 +39,18 @@ poll_findall, run_findall, ) +from parallel_web_tools.core.memory import ( + MAX_MEMORY_QUERY_CHARS, + MAX_MEMORY_RESULTS, + MEMORY_KINDS, + MemoryApiError, + MemoryInputError, + MemoryKind, + clear_memory, + evict_memory, + retrieve_memory, + validate_memory_scope_key, +) from parallel_web_tools.core.monitor import ( MONITOR_EVENT_TYPES, MONITOR_FREQUENCY_PRESETS, @@ -139,6 +151,17 @@ "get_research_status", "poll_research", "run_research", + # Memory + "MAX_MEMORY_QUERY_CHARS", + "MAX_MEMORY_RESULTS", + "MEMORY_KINDS", + "MemoryApiError", + "MemoryInputError", + "MemoryKind", + "clear_memory", + "evict_memory", + "retrieve_memory", + "validate_memory_scope_key", # FindAll "ENTITY_SEARCH_ENTITY_TYPES", "FINDALL_GENERATORS", diff --git a/parallel_web_tools/core/findall.py b/parallel_web_tools/core/findall.py index cf8aa03..6e69a6f 100644 --- a/parallel_web_tools/core/findall.py +++ b/parallel_web_tools/core/findall.py @@ -17,6 +17,7 @@ from typing import Any, Literal, cast from parallel_web_tools.core.auth import create_client +from parallel_web_tools.core.memory import validate_memory_scope_key from parallel_web_tools.core.polling import poll_until from parallel_web_tools.core.user_agent import ClientSource @@ -106,6 +107,7 @@ def create_findall_run( metadata: dict[str, Any] | None = None, api_key: str | None = None, source: ClientSource = "python", + memory_scope_key: str | None = None, ) -> dict[str, Any]: """Create a FindAll run without waiting for results. @@ -119,6 +121,8 @@ def create_findall_run( metadata: Optional metadata dict. api_key: Optional API key override. source: Client source identifier for User-Agent. + memory_scope_key: Optional key identifying the memory scope to use. + Omit to use personal memory, if available. Returns: Dict with findall_id, status, generator, and timestamps. @@ -137,6 +141,9 @@ def create_findall_run( if metadata: kwargs["metadata"] = metadata + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + kwargs["memory_scope_key"] = scope_key run = client.beta.findall.create(**kwargs) status_info = _extract_status_info(run) @@ -405,6 +412,7 @@ def run_findall( on_status: FindAllStatusCallback | None = None, source: ClientSource = "python", enrich: bool = True, + memory_scope_key: str | None = None, ) -> dict[str, Any]: """Ingest, create, and poll a FindAll run to completion. @@ -427,6 +435,8 @@ def run_findall( on_status: Optional callback(status, findall_id, metrics) on each poll. source: Client source identifier for User-Agent. enrich: Whether to apply suggested enrichments from ingest. Default True. + memory_scope_key: Optional key identifying the memory scope to use. + Omit to use personal memory, if available. Returns: Dict with findall_id, status, metrics, and candidates. @@ -460,6 +470,9 @@ def run_findall( if metadata: kwargs["metadata"] = metadata + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + kwargs["memory_scope_key"] = scope_key run = client.beta.findall.create(**kwargs) findall_id = run.findall_id diff --git a/parallel_web_tools/core/memory.py b/parallel_web_tools/core/memory.py new file mode 100644 index 0000000..4990912 --- /dev/null +++ b/parallel_web_tools/core/memory.py @@ -0,0 +1,159 @@ +"""Retrieve and manage Parallel Memory through the generated Python SDK.""" + +from __future__ import annotations + +import datetime +import re +from typing import Any, Literal + +from parallel_web_tools.core.auth import create_client +from parallel_web_tools.core.user_agent import ClientSource + +MemoryKind = Literal["task", "monitor", "findall"] + +MEMORY_KINDS: tuple[MemoryKind, ...] = ("task", "monitor", "findall") +MAX_MEMORY_QUERY_CHARS = 500 +MAX_MEMORY_RESULTS = 25 +MEMORY_IDENTIFIER_PATTERN = r"^[a-zA-Z0-9_-]{1,128}$" +_MEMORY_IDENTIFIER_RE = re.compile(MEMORY_IDENTIFIER_PATTERN) + + +class MemoryInputError(ValueError): + """Raised when a Memory request is invalid before it reaches the API.""" + + +class MemoryApiError(RuntimeError): + """Raised when the Memory SDK returns a malformed response.""" + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + body: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body + + +def validate_memory_identifier(value: str, field: str) -> str: + """Validate a scope key or source ID against the public API contract.""" + if not _MEMORY_IDENTIFIER_RE.fullmatch(value): + raise MemoryInputError(f"{field} must be 1-128 ASCII letters, digits, underscores, or hyphens") + return value + + +def validate_memory_scope_key(memory_scope_key: str | None) -> str | None: + """Validate and return a Memory scope key, preserving ``None`` for personal memory.""" + if memory_scope_key is None: + return None + return validate_memory_identifier(memory_scope_key, "memory_scope_key") + + +def _normalize_since(value: str | datetime.datetime) -> str: + """Return an RFC 3339 timestamp after verifying that it has a timezone.""" + if isinstance(value, datetime.datetime): + parsed = value + rendered = value.isoformat() + else: + rendered = value + normalized = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value + try: + parsed = datetime.datetime.fromisoformat(normalized) + except ValueError as exc: + raise MemoryInputError("since must be an RFC 3339 timestamp") from exc + + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise MemoryInputError("since must include a timezone") + return rendered + + +def _serialize_sdk_response(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if hasattr(value, "model_dump"): + data = value.model_dump(mode="json") + if isinstance(data, dict): + return data + if hasattr(value, "to_dict"): + data = value.to_dict() + if isinstance(data, dict): + return data + raise MemoryApiError("Memory SDK returned an unexpected response") + + +def retrieve_memory( + query: str | None = "", + limit: int = 10, + *, + kind: MemoryKind | None = None, + since: str | datetime.datetime | None = None, + memory_scope_key: str | None = None, + api_key: str | None = None, + source: ClientSource = "python", +) -> dict[str, Any]: + """Retrieve relevant or recent saved Task, Monitor, and FindAll runs.""" + if query is not None and len(query) > MAX_MEMORY_QUERY_CHARS: + raise MemoryInputError(f"query must be at most {MAX_MEMORY_QUERY_CHARS} characters") + if not 1 <= limit <= MAX_MEMORY_RESULTS: + raise MemoryInputError(f"limit must be between 1 and {MAX_MEMORY_RESULTS}") + if kind is not None and kind not in MEMORY_KINDS: + raise MemoryInputError(f"kind must be one of: {', '.join(MEMORY_KINDS)}") + + kwargs: dict[str, Any] = {"query": query, "limit": limit} + if kind is not None: + kwargs["kind"] = kind + if since is not None: + kwargs["since"] = _normalize_since(since) + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + kwargs["memory_scope_key"] = scope_key + + client = create_client(api_key, source) + result = _serialize_sdk_response(client.beta.memory.retrieve(**kwargs)) + if not isinstance(result.get("results"), list): + raise MemoryApiError("Memory retrieve returned an unexpected response") + return result + + +def evict_memory( + kind: MemoryKind, + source_id: str, + *, + memory_scope_key: str | None = None, + api_key: str | None = None, + source: ClientSource = "python", +) -> dict[str, Any]: + """Remove one source from Memory without deleting the underlying resource.""" + if kind not in MEMORY_KINDS: + raise MemoryInputError(f"kind must be one of: {', '.join(MEMORY_KINDS)}") + + kwargs: dict[str, Any] = { + "kind": kind, + "id": validate_memory_identifier(source_id, "source id"), + } + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + kwargs["memory_scope_key"] = scope_key + + client = create_client(api_key, source) + client.beta.memory.evict(**kwargs) + return {"ok": True, "action": "evict"} + + +def clear_memory( + *, + memory_scope_key: str | None = None, + api_key: str | None = None, + source: ClientSource = "python", +) -> dict[str, Any]: + """Permanently clear personal Memory or a scoped Memory.""" + kwargs: dict[str, Any] = {} + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + kwargs["memory_scope_key"] = scope_key + + client = create_client(api_key, source) + client.beta.memory.clear(**kwargs) + return {"ok": True, "action": "clear"} diff --git a/parallel_web_tools/core/monitor.py b/parallel_web_tools/core/monitor.py index 17ebe76..77e4e7c 100644 --- a/parallel_web_tools/core/monitor.py +++ b/parallel_web_tools/core/monitor.py @@ -16,6 +16,7 @@ from typing import Any from parallel_web_tools.core.auth import create_client +from parallel_web_tools.core.memory import validate_memory_scope_key from parallel_web_tools.core.user_agent import ClientSource # Friendly aliases for SDK frequency strings. @@ -72,6 +73,7 @@ def create_monitor( processor: str | None = None, api_key: str | None = None, source: ClientSource = "python", + memory_scope_key: str | None = None, ) -> dict[str, Any]: """Create a new monitor. @@ -92,6 +94,8 @@ def create_monitor( processor: ``lite`` (default, fast/cheap) or ``base`` (more thorough). api_key: Optional API key override. source: Client source identifier for User-Agent. + memory_scope_key: Optional key identifying the memory scope to use. + Omit to use personal memory, if available. Returns: Dict representation of the created Monitor. @@ -126,6 +130,9 @@ def create_monitor( if processor is not None: kwargs["processor"] = processor + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + kwargs["memory_scope_key"] = scope_key return _to_dict(client.monitor.create(**kwargs)) diff --git a/parallel_web_tools/core/research.py b/parallel_web_tools/core/research.py index 855ce55..256efc5 100644 --- a/parallel_web_tools/core/research.py +++ b/parallel_web_tools/core/research.py @@ -12,6 +12,7 @@ from typing import Any, Literal from parallel_web_tools.core.auth import create_client +from parallel_web_tools.core.memory import validate_memory_scope_key from parallel_web_tools.core.polling import poll_until from parallel_web_tools.core.user_agent import ClientSource @@ -100,6 +101,7 @@ def create_research_task( previous_interaction_id: str | None = None, output_schema: OutputSchemaType = "auto", text_description: str | None = None, + memory_scope_key: str | None = None, ) -> dict[str, Any]: """Create a deep research task without waiting for results. @@ -114,6 +116,8 @@ def create_research_task( "text" (markdown report with inline citations). text_description: Optional steering description for text-schema reports (e.g. "Keep under 1000 words, focus on M&A activity"). + memory_scope_key: Optional key identifying the memory scope to use. + Omit to use personal memory, if available. Returns: Dict with run_id, interaction_id, result_url, output_schema, and other metadata. @@ -130,6 +134,9 @@ def create_research_task( if task_spec is not None: create_kwargs["task_spec"] = task_spec + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + create_kwargs["memory_scope_key"] = scope_key task = client.task_run.create(**create_kwargs) return { @@ -289,6 +296,7 @@ def run_research( previous_interaction_id: str | None = None, output_schema: OutputSchemaType = "auto", text_description: str | None = None, + memory_scope_key: str | None = None, ) -> dict[str, Any]: """Run deep research and wait for results. @@ -308,6 +316,8 @@ def run_research( output_schema: "auto" (default; API-chosen structured output) or "text" (markdown report with inline citations). text_description: Optional steering description for text-schema reports. + memory_scope_key: Optional key identifying the memory scope to use. + Omit to use personal memory, if available. Returns: Dict with content and metadata, including the requested output_schema. @@ -328,6 +338,9 @@ def run_research( if task_spec is not None: create_kwargs["task_spec"] = task_spec + scope_key = validate_memory_scope_key(memory_scope_key) + if scope_key is not None: + create_kwargs["memory_scope_key"] = scope_key task = client.task_run.create(**create_kwargs) run_id = task.run_id interaction_id = getattr(task, "interaction_id", run_id) diff --git a/pyproject.toml b/pyproject.toml index 7147b28..b225db9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ ] dependencies = [ - "parallel-web>=1.1.0,<2", + "parallel-web>=1.2.0,<2", "python-dotenv>=1.0.0", # CLI dependencies (minimal - search, extract, enrich with CLI args) "click>=8.1.0", diff --git a/tests/test_findall.py b/tests/test_findall.py index 1fe7d5e..5afaaa9 100644 --- a/tests/test_findall.py +++ b/tests/test_findall.py @@ -332,6 +332,19 @@ def test_create_omits_none_optionals(self, mock_parallel_client): assert "exclude_list" not in call_kwargs assert "metadata" not in call_kwargs + def test_create_passes_memory_scope_to_sdk(self, mock_parallel_client): + mock_parallel_client.beta.findall.create.return_value = _make_run() + + create_findall_run( + objective="q", + entity_type="entities", + match_conditions=[], + memory_scope_key="workspace_acme", + ) + + call_kwargs = mock_parallel_client.beta.findall.create.call_args.kwargs + assert call_kwargs["memory_scope_key"] == "workspace_acme" + class TestCancelFindallRun: """Tests for cancel_findall_run function.""" @@ -695,6 +708,36 @@ def test_run_no_wait(self, runner): mock_ingest.assert_called_once() mock_create.assert_called_once() + def test_run_no_wait_passes_memory_scope_key(self, runner): + with ( + mock.patch("parallel_web_tools.cli.commands.ingest_findall") as mock_ingest, + mock.patch("parallel_web_tools.cli.commands.create_findall_run") as mock_create, + ): + mock_ingest.return_value = { + "entity_type": "companies", + "match_conditions": [], + } + mock_create.return_value = { + "findall_id": "findall_memory", + "status": "queued", + "generator": "core", + } + + result = runner.invoke( + main, + [ + "findall", + "run", + "Find companies", + "--no-wait", + "--memory-scope-key", + "workspace_acme", + ], + ) + + assert result.exit_code == 0 + assert mock_create.call_args.kwargs["memory_scope_key"] == "workspace_acme" + def test_run_no_wait_json(self, runner): with ( mock.patch("parallel_web_tools.cli.commands.ingest_findall") as mock_ingest, diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..8ae41c1 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,278 @@ +"""Tests for the Parallel Memory SDK integration and CLI.""" + +from __future__ import annotations + +import datetime +import json +from unittest import mock + +import pytest +from click.testing import CliRunner + +from parallel_web_tools.cli.commands import main +from parallel_web_tools.core.memory import ( + MemoryApiError, + MemoryInputError, + clear_memory, + evict_memory, + retrieve_memory, +) + + +class FakeModel: + def __init__(self, data: dict) -> None: + self.data = data + + def model_dump(self, mode: str = "python") -> dict: + assert mode == "json" + return self.data + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def memory_client(): + client = mock.MagicMock() + client.beta.memory.retrieve.return_value = FakeModel({"results": []}) + with mock.patch("parallel_web_tools.core.memory.create_client", return_value=client) as create: + yield client, create + + +class TestMemoryValidation: + def test_public_package_exports_memory_operations(self): + import parallel_web_tools + + assert parallel_web_tools.retrieve_memory is retrieve_memory + assert parallel_web_tools.evict_memory is evict_memory + assert parallel_web_tools.clear_memory is clear_memory + + @pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"query": "x" * 501}, "query"), + ({"limit": 0}, "limit"), + ({"limit": 26}, "limit"), + ({"since": "2026-07-15T17:30:00"}, "timezone"), + ({"memory_scope_key": "contains spaces"}, "memory_scope_key"), + ], + ) + def test_rejects_invalid_retrieve_fields(self, kwargs, message): + with pytest.raises(MemoryInputError, match=message): + retrieve_memory(**kwargs) + + +class TestMemorySdk: + def test_retrieve_passes_filtered_payload_to_sdk(self, memory_client): + client, create = memory_client + + result = retrieve_memory( + "serverless inference", + 25, + kind="findall", + since="2026-07-15T17:30:00Z", + memory_scope_key="workspace_acme", + api_key="test-api-key", + source="cli", + ) + + assert result == {"results": []} + create.assert_called_once_with("test-api-key", "cli") + client.beta.memory.retrieve.assert_called_once_with( + query="serverless inference", + limit=25, + kind="findall", + since="2026-07-15T17:30:00Z", + memory_scope_key="workspace_acme", + ) + + def test_retrieve_serializes_datetime_for_sdk(self, memory_client): + client, _ = memory_client + since = datetime.datetime(2026, 7, 15, 17, 30, tzinfo=datetime.timezone.utc) + + retrieve_memory(since=since) + + assert client.beta.memory.retrieve.call_args.kwargs["since"] == "2026-07-15T17:30:00+00:00" + + def test_retrieve_rejects_malformed_sdk_response(self, memory_client): + client, _ = memory_client + client.beta.memory.retrieve.return_value = FakeModel({"unexpected": []}) + + with pytest.raises(MemoryApiError, match="unexpected response"): + retrieve_memory() + + def test_evict_calls_sdk_and_returns_stable_acknowledgement(self, memory_client): + client, _ = memory_client + + result = evict_memory("task", "trun_example", memory_scope_key="workspace_acme") + + assert result == {"ok": True, "action": "evict"} + client.beta.memory.evict.assert_called_once_with( + kind="task", + id="trun_example", + memory_scope_key="workspace_acme", + ) + + def test_clear_personal_memory_calls_sdk_without_scope(self, memory_client): + client, _ = memory_client + + result = clear_memory() + + assert result == {"ok": True, "action": "clear"} + client.beta.memory.clear.assert_called_once_with() + + +class TestMemoryCli: + def test_help_lists_all_operations(self, runner): + result = runner.invoke(main, ["memory", "--help"]) + + assert result.exit_code == 0 + assert "Search and manage saved Task, Monitor, and FindAll entries." in result.output + assert "clear Remove all entries from selected Memory." in result.output + assert "evict Remove one entry from Memory." in result.output + assert "retrieve Search Memory or list recent entries." in result.output + + def test_retrieve_help_marks_query_as_optional(self, runner): + result = runner.invoke(main, ["memory", "retrieve", "--help"]) + + assert result.exit_code == 0 + assert "[OPTIONS] [QUERY]" in result.output + + def test_retrieve_json(self, runner): + with mock.patch("parallel_web_tools.cli.commands.retrieve_memory") as retrieve: + retrieve.return_value = { + "results": [ + { + "kind": "task", + "id": "trun_example", + "updated_at": "2026-07-29T18:20:00Z", + "input_excerpt": "Research vendors", + "output_excerpt": "Prior findings", + } + ] + } + result = runner.invoke( + main, + [ + "memory", + "retrieve", + "--query", + "serverless inference", + "--limit", + "5", + "--kind", + "task", + "--scope-key", + "workspace_acme", + "--json", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.output)["results"][0]["id"] == "trun_example" + retrieve.assert_called_once_with( + query="serverless inference", + limit=5, + kind="task", + since=None, + memory_scope_key="workspace_acme", + source="cli", + ) + + def test_retrieve_human_output(self, runner): + with mock.patch("parallel_web_tools.cli.commands.retrieve_memory") as retrieve: + retrieve.return_value = { + "results": [ + { + "kind": "findall", + "id": "findall_example", + "updated_at": "2026-07-29T18:20:00Z", + "input_excerpt": "Find vendors", + "matched_count": 17, + } + ] + } + result = runner.invoke(main, ["memory", "retrieve", "inference vendors"]) + + assert result.exit_code == 0 + assert "Found 1 entry in Memory." in result.output + assert "findall_example" in result.output + assert "17" in result.output + + def test_retrieve_empty_human_output(self, runner): + with mock.patch("parallel_web_tools.cli.commands.retrieve_memory") as retrieve: + retrieve.return_value = {"results": []} + result = runner.invoke(main, ["memory", "retrieve", "inference vendors"]) + + assert result.exit_code == 0 + assert "No Memory entries found." in result.output + + def test_retrieve_renders_monitor_event_id(self, runner): + with mock.patch("parallel_web_tools.cli.commands.retrieve_memory") as retrieve: + retrieve.return_value = { + "results": [ + { + "kind": "monitor", + "id": "monitor_example", + "updated_at": "2026-07-29T18:20:00Z", + "status": "active", + "matched_events": [ + { + "event_id": "mevt_example", + "detected_at": "2026-07-29T18:15:00Z", + "excerpt": "Pricing changed", + } + ], + } + ] + } + result = runner.invoke(main, ["memory", "retrieve", "pricing changes"]) + + assert result.exit_code == 0 + assert "Event mevt_example" in result.output + assert "Event unknown" not in result.output + + def test_evict_json(self, runner): + with mock.patch("parallel_web_tools.cli.commands.evict_memory") as evict: + evict.return_value = {"ok": True, "action": "evict"} + result = runner.invoke( + main, + ["memory", "evict", "--kind", "task", "--id", "trun_example", "--json"], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"ok": True, "action": "evict"} + + def test_evict_human_output_uses_entry_terminology(self, runner): + with mock.patch("parallel_web_tools.cli.commands.evict_memory") as evict: + evict.return_value = {"ok": True, "action": "evict"} + result = runner.invoke( + main, + ["memory", "evict", "--kind", "task", "--id", "trun_example"], + ) + + assert result.exit_code == 0 + assert "Removed task entry trun_example from personal Memory." in result.output + assert "underlying Task, Monitor, or FindAll resource was not deleted" in result.output + + def test_clear_requires_explicit_confirmation(self, runner): + with mock.patch("parallel_web_tools.cli.commands.clear_memory") as clear: + result = runner.invoke(main, ["memory", "clear", "--scope-key", "workspace_acme", "--json"]) + + assert result.exit_code == 2 + assert "confirm-clear" in json.loads(result.output)["error"]["message"] + clear.assert_not_called() + + def test_clear_confirmed(self, runner): + with mock.patch("parallel_web_tools.cli.commands.clear_memory") as clear: + clear.return_value = {"ok": True, "action": "clear"} + result = runner.invoke( + main, + ["memory", "clear", "--scope-key", "workspace_acme", "--confirm-clear", "--json"], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"ok": True, "action": "clear"} + clear.assert_called_once_with(memory_scope_key="workspace_acme", source="cli") diff --git a/tests/test_monitor.py b/tests/test_monitor.py index d1dd093..48424c0 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -133,6 +133,14 @@ def test_snapshot_type(self, mock_client): assert kwargs["type"] == "snapshot" assert kwargs["settings"] == {"task_run_id": "trun_xyz"} + def test_passes_memory_scope_to_sdk(self, mock_client): + mock_client.monitor.create.return_value = _model(monitor_id="mon_memory") + + create_monitor("track stuff", memory_scope_key="workspace_acme") + + kwargs = mock_client.monitor.create.call_args.kwargs + assert kwargs["memory_scope_key"] == "workspace_acme" + def test_event_stream_requires_query(self, mock_client): with pytest.raises(ValueError, match="query is required"): create_monitor(None, "1d") @@ -348,6 +356,23 @@ def test_json_output(self, runner): assert result.exit_code == 0 assert json.loads(result.output)["monitor_id"] == "mon_json" + def test_passes_memory_scope_key(self, runner): + with mock.patch("parallel_web_tools.cli.commands.create_monitor") as patched: + patched.return_value = {"monitor_id": "mon_memory"} + result = runner.invoke( + main, + [ + "monitor", + "create", + "track stuff", + "--memory-scope-key", + "workspace_acme", + ], + ) + + assert result.exit_code == 0 + assert patched.call_args.kwargs["memory_scope_key"] == "workspace_acme" + def test_invalid_metadata_json(self, runner): result = runner.invoke(main, ["monitor", "create", "test", "--metadata", "not-json"]) assert result.exit_code != 0 diff --git a/tests/test_research.py b/tests/test_research.py index 53c0d4d..f2ebaf6 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -78,6 +78,16 @@ def test_create_task_auto_schema_no_task_spec(self, mock_parallel_client): call_args = mock_parallel_client.task_run.create.call_args assert "task_spec" not in call_args.kwargs + def test_create_task_passes_memory_scope_to_sdk(self, mock_parallel_client): + mock_task = mock.MagicMock() + mock_task.run_id = "trun_memory" + mock_parallel_client.task_run.create.return_value = mock_task + + create_research_task("What is AI?", memory_scope_key="workspace_acme") + + call_kwargs = mock_parallel_client.task_run.create.call_args.kwargs + assert call_kwargs["memory_scope_key"] == "workspace_acme" + def test_create_task_text_schema(self, mock_parallel_client): """Should pass task_spec with text schema when output_schema='text'.""" mock_task = mock.MagicMock() @@ -408,6 +418,30 @@ def test_research_run_no_wait(self, runner): assert "trun_123" in result.output mock_create.assert_called_once() + def test_research_run_passes_memory_scope_key(self, runner): + with mock.patch("parallel_web_tools.cli.commands.create_research_task") as mock_create: + mock_create.return_value = { + "run_id": "trun_memory", + "interaction_id": "trun_memory", + "result_url": "https://platform.parallel.ai/play/deep-research/trun_memory", + "status": "pending", + } + + result = runner.invoke( + main, + [ + "research", + "run", + "Workspace research", + "--no-wait", + "--memory-scope-key", + "workspace_acme", + ], + ) + + assert result.exit_code == 0 + assert mock_create.call_args.kwargs["memory_scope_key"] == "workspace_acme" + def test_research_run_json_output(self, runner): """Should output JSON with --json flag.""" with mock.patch("parallel_web_tools.cli.commands.create_research_task") as mock_create: diff --git a/uv.lock b/uv.lock index 010a18f..da8fd05 100644 --- a/uv.lock +++ b/uv.lock @@ -1657,7 +1657,7 @@ wheels = [ [[package]] name = "parallel-web" -version = "1.1.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1667,14 +1667,14 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/1d/fefb7d976d3ea2ae61b0d9d6d638c9f837cb9fb96532680b148f33f322a1/parallel_web-1.1.0.tar.gz", hash = "sha256:97d6dbe4aa49b8c2c93f71f818d8883bc45bee71928bd945b7f616cecaacec0d", size = 157720, upload-time = "2026-06-08T18:22:21.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/fc/4bc3d44a1927a2b253662f1359053cf43529c940df93bc22139f494f7deb/parallel_web-1.2.0.tar.gz", hash = "sha256:8fa8120cc92b0567cd095cbcb85520856984b4b4ea6ac20ad2fa85eec4b713ab", size = 161683, upload-time = "2026-08-10T18:16:09.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/ec/276c02247c973b02e254c9daac1844ccd38f385850c50e00ad46548eb5db/parallel_web-1.1.0-py3-none-any.whl", hash = "sha256:598aa5613c1146a3880a41592c343b0b9c0f51cff7c1ea6d10bc586d2df5e2f9", size = 167673, upload-time = "2026-06-08T18:22:20.068Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/bb8d0c10a9219af875fdfa0a68f5e2fb6aa4f716dc9d7b9338d77a7f78a7/parallel_web-1.2.0-py3-none-any.whl", hash = "sha256:a050382c98ec6b7dac71988a67cd206e99740fb0a68bca2af6f7c403ed54f5aa", size = 175758, upload-time = "2026-08-10T18:16:08.634Z" }, ] [[package]] name = "parallel-web-tools" -version = "0.7.1rc2" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "click" }, @@ -1765,7 +1765,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.0" }, { name = "nest-asyncio", marker = "extra == 'duckdb'", specifier = ">=1.6.0" }, { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.3.0" }, - { name = "parallel-web", specifier = ">=1.1.0,<2" }, + { name = "parallel-web", specifier = ">=1.2.0,<2" }, { name = "parallel-web-tools", extras = ["all", "spark"], marker = "extra == 'dev'" }, { name = "parallel-web-tools", extras = ["cli"], marker = "extra == 'bigquery'" }, { name = "parallel-web-tools", extras = ["cli", "polars"], marker = "extra == 'duckdb'" },