diff --git a/README.md b/README.md index c54b66358..40ebc6118 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Atelier helps teams ship the same context runtime everywhere: on the command lin The runtime separates **Passive Tracking** (enabled by default) from **Active Context** (requires Development Mode). +![Atelier Overview dashboard](docs/assets/screenshots/overview.png) + ## Passive Tracking (Production Ready) - **Sessions & Ledger** — track every agent run and execution state @@ -15,6 +17,8 @@ The runtime separates **Passive Tracking** (enabled by default) from **Active Co - **Traces** — record observable execution history (files, commands, errors) - **Tools & Agents** — central registry of available capabilities and personas +![Cost & efficiency analytics across hosts, models, and tools](docs/assets/screenshots/analytics.png) + ## Active Context (Development Mode) _Enable with `ATELIER_DEV_MODE=1`_ diff --git a/docs/assets/screenshots/analytics.png b/docs/assets/screenshots/analytics.png new file mode 100644 index 000000000..6ae785826 Binary files /dev/null and b/docs/assets/screenshots/analytics.png differ diff --git a/docs/assets/screenshots/external.png b/docs/assets/screenshots/external.png new file mode 100644 index 000000000..75096f6c1 Binary files /dev/null and b/docs/assets/screenshots/external.png differ diff --git a/docs/assets/screenshots/overview.png b/docs/assets/screenshots/overview.png new file mode 100644 index 000000000..cbfde565a Binary files /dev/null and b/docs/assets/screenshots/overview.png differ diff --git a/docs/assets/screenshots/sessions.png b/docs/assets/screenshots/sessions.png new file mode 100644 index 000000000..b5d172d3c Binary files /dev/null and b/docs/assets/screenshots/sessions.png differ diff --git a/docs/assets/screenshots/telemetry.png b/docs/assets/screenshots/telemetry.png new file mode 100644 index 000000000..1c8c13494 Binary files /dev/null and b/docs/assets/screenshots/telemetry.png differ diff --git a/docs/assets/screenshots/tools.png b/docs/assets/screenshots/tools.png new file mode 100644 index 000000000..e5d69377e Binary files /dev/null and b/docs/assets/screenshots/tools.png differ diff --git a/frontend/src/pages/Traces.test.tsx b/frontend/src/pages/Traces.test.tsx index 05bcf2f95..c878c68be 100644 --- a/frontend/src/pages/Traces.test.tsx +++ b/frontend/src/pages/Traces.test.tsx @@ -109,4 +109,83 @@ describe("Traces page", () => { expect(screen.getByText(/run-timeout/i)).toBeInTheDocument(); expect(screen.getByText(/Commands:/i)).toBeInTheDocument(); }); + + it("hides stale or unrelated highlighted snippets for the current search", async () => { + const user = userEvent.setup(); + vi.spyOn(globalThis, "fetch").mockImplementation( + (input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes("/api/traces")) { + const params = new URL(url, "http://localhost").searchParams; + const query = params.get("query"); + return Promise.resolve( + jsonResponse({ + items: [ + { + id: query ? "trace-sidecar" : "trace-base", + session_id: query ? "run-sidecar" : "run-base", + agent: "codex", + host: "codex", + domain: "coding", + task: query ? "Investigate sidecar session" : "Base session", + status: "success", + files_touched: [], + tools_called: [], + commands_run: [], + errors_seen: [], + repeated_failures: [], + validation_results: [], + created_at: "2026-05-12T00:00:00Z", + snippets: query + ? [ + "Tools: [[shopify]] sync service skills", + "Commands: inspect [[sidecar]] process logs", + ] + : [], + }, + ], + metrics: { + stats: { + total: 1, + success: 1, + failed: 0, + partial: 0, + }, + hosts: ["codex"], + domains: ["coding"], + }, + }) + ); + } + + return Promise.resolve(new Response("not found", { status: 404 })); + } + ); + + render( + + + } /> + + + ); + + await waitFor(() => { + expect(screen.getByText("Base session")).toBeInTheDocument(); + }); + + await user.type( + screen.getByPlaceholderText( + /Search tasks, reasoning, tools, commands, files, validations, and summaries/i + ), + "sidecar" + ); + + expect( + await screen.findByText("Investigate sidecar session") + ).toBeInTheDocument(); + expect(screen.getByText(/inspect/i)).toBeInTheDocument(); + expect(screen.queryByText(/shopify/i)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/pages/Traces.tsx b/frontend/src/pages/Traces.tsx index 010009007..1b2ebfdfc 100644 --- a/frontend/src/pages/Traces.tsx +++ b/frontend/src/pages/Traces.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useRef } from "react"; import { useSearchParams } from "react-router-dom"; import { api, @@ -27,6 +27,7 @@ export default function Traces() { const [query, setQuery] = useState(initialQuery); const [searchInput, setSearchInput] = useState(initialQuery); const [page, setPage] = useState(0); + const tracesRequestSeq = useRef(0); useEffect(() => { const urlQuery = searchParams.get("q") ?? ""; @@ -70,21 +71,28 @@ export default function Traces() { // Fetch traces when filters change useEffect(() => { + const requestSeq = ++tracesRequestSeq.current; + let cancelled = false; setLoading(true); setPage(0); setErr(null); api .traces(50, 0, domainFilter, hostFilter, query) .then((res) => { + if (cancelled || requestSeq !== tracesRequestSeq.current) return; setItems(res.items); setMetrics(res.metrics); setHasMore(res.items.length >= 50); setLoading(false); }) .catch((e) => { + if (cancelled || requestSeq !== tracesRequestSeq.current) return; setErr(String(e)); setLoading(false); }); + return () => { + cancelled = true; + }; }, [domainFilter, hostFilter, query]); useEffect(() => { @@ -126,11 +134,13 @@ export default function Traces() { const loadMore = () => { if (loading || !hasMore) return; + const requestSeq = tracesRequestSeq.current; setLoading(true); const nextOffset = (page + 1) * 50; api .traces(50, nextOffset, domainFilter, hostFilter, query) .then((res) => { + if (requestSeq !== tracesRequestSeq.current) return; setItems((prev) => (prev ? [...prev, ...res.items] : res.items)); setMetrics(res.metrics); setHasMore(res.items.length >= 50); @@ -138,6 +148,7 @@ export default function Traces() { setLoading(false); }) .catch((e) => { + if (requestSeq !== tracesRequestSeq.current) return; setErr(String(e)); setLoading(false); }); @@ -296,6 +307,7 @@ export default function Traces() { toggleExpanded(t.id)} onOpenInspector={() => openInspector(t)} @@ -337,11 +349,13 @@ export default function Traces() { function TraceCard({ trace, + searchQuery, isExpanded, onToggle, onOpenInspector, }: { trace: Trace; + searchQuery: string; isExpanded: boolean; onToggle: () => void; onOpenInspector: () => void; @@ -393,7 +407,7 @@ function TraceCard({ {trace.task}

{trace.snippets && trace.snippets.length > 0 && ( - + )}
Session: {trace.session_id} @@ -415,10 +429,21 @@ function TraceCard({ ); } -function TraceSearchHits({ snippets }: { snippets: string[] }) { +function TraceSearchHits({ + snippets, + query, +}: { + snippets: string[]; + query: string; +}) { + const matchingSnippets = snippets.filter((snippet) => + snippetMatchesSearchQuery(snippet, query) + ); + if (matchingSnippets.length === 0) return null; + return (
- {snippets.slice(0, 4).map((snippet, index) => ( + {matchingSnippets.slice(0, 4).map((snippet, index) => (
(match[1] || match[2] || "").trim().toLowerCase()) + .flatMap((term) => term.split(/[^0-9a-z_]+/i)) + .filter(Boolean); + return [...new Set(terms)]; +} + +function snippetMatchesSearchQuery(snippet: string, query: string): boolean { + const terms = searchTerms(query); + if (terms.length === 0) return true; + + const markedTerms = Array.from(snippet.matchAll(/\[\[(.*?)\]\]/g)) + .map((match) => match[1].trim().toLowerCase()) + .flatMap((term) => term.split(/[^0-9a-z_]+/i)) + .filter(Boolean); + + return markedTerms.some((marked) => + terms.some((term) => marked.startsWith(term) || term.startsWith(marked)) + ); +} + function SnippetText({ text }: { text: string }) { const compact = text.replace(/\s+/g, " ").trim(); const parts = compact.split(/(\[\[.*?\]\])/g); diff --git a/scripts/install.sh b/scripts/install.sh index 0d2634d2e..56e77e793 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -335,37 +335,46 @@ main() { fi if [[ "$ATELIER_NO_SERVICECTL" != "1" ]]; then - info "Starting Atelier background service controller..." - if [[ "$ATELIER_DRY_RUN" == "1" ]]; then - echo "[dry-run] $ATELIER_BIN_DIR/atelier servicectl start --interval-seconds $ATELIER_SERVICECTL_INTERVAL_SECONDS --maintenance-interval-seconds $ATELIER_SERVICECTL_MAINTENANCE_INTERVAL_SECONDS" - else - "$ATELIER_BIN_DIR/atelier" servicectl start \ - --interval-seconds "$ATELIER_SERVICECTL_INTERVAL_SECONDS" \ - --maintenance-interval-seconds "$ATELIER_SERVICECTL_MAINTENANCE_INTERVAL_SECONDS" >/dev/null - fi - else - info "Skipping background service controller because ATELIER_NO_SERVICECTL=1" - fi + if command -v systemctl >/dev/null 2>&1 || [[ "$(uname -s)" == "Darwin" ]]; then + info "Registering Atelier services with background manager..." + local background_args=() + if [[ "$ATELIER_NO_STACK" != "1" && $(command -v docker) ]]; then + background_args+=("--with-stack") + fi - STACK_STARTED=0 - if [[ "$ATELIER_NO_STACK" != "1" ]]; then - if command -v docker >/dev/null 2>&1; then - info "Starting Atelier visualization stack (service + frontend)..." if [[ "$ATELIER_DRY_RUN" == "1" ]]; then - echo "[dry-run] $ATELIER_BIN_DIR/atelier stack start" + echo "[dry-run] $ATELIER_BIN_DIR/atelier background install ${background_args[*]}" else - "$ATELIER_BIN_DIR/atelier" stack start \ - && STACK_STARTED=1 \ - || warn "Visualization stack did not start (Docker daemon may not be running)" + "$ATELIER_BIN_DIR/atelier" background install "${background_args[@]}" >/dev/null fi else - info "Skipping visualization stack because Docker is not installed" + info "Starting Atelier background service controller (loose process)..." + if [[ "$ATELIER_DRY_RUN" == "1" ]]; then + echo "[dry-run] $ATELIER_BIN_DIR/atelier servicectl start --interval-seconds $ATELIER_SERVICECTL_INTERVAL_SECONDS --maintenance-interval-seconds $ATELIER_SERVICECTL_MAINTENANCE_INTERVAL_SECONDS" + else + "$ATELIER_BIN_DIR/atelier" servicectl start \ + --interval-seconds "$ATELIER_SERVICECTL_INTERVAL_SECONDS" \ + --maintenance-interval-seconds "$ATELIER_SERVICECTL_MAINTENANCE_INTERVAL_SECONDS" >/dev/null + fi + + if [[ "$ATELIER_NO_STACK" != "1" ]]; then + if command -v docker >/dev/null 2>&1; then + info "Starting Atelier visualization stack (service + frontend)..." + if [[ "$ATELIER_DRY_RUN" == "1" ]]; then + echo "[dry-run] $ATELIER_BIN_DIR/atelier stack start" + else + "$ATELIER_BIN_DIR/atelier" stack start \ + && STACK_STARTED=1 \ + || warn "Visualization stack did not start (Docker daemon may not be running)" + fi + fi + fi fi else - info "Skipping visualization stack because ATELIER_NO_STACK=1" + info "Skipping background services because ATELIER_NO_SERVICECTL=1" fi - if [[ "$STACK_STARTED" == "1" ]]; then + if [[ "$STACK_STARTED" == "1" || ( "$ATELIER_NO_SERVICECTL" != "1" && $(command -v systemctl) && "$ATELIER_NO_STACK" != "1" ) ]]; then echo " Visualization stack is running:" echo " frontend: http://localhost:3125" echo " service: http://localhost:8787" @@ -374,7 +383,7 @@ main() { echo " Commands:" echo " atelier --version - Check core CLI version" echo " atelier-mcp --version - Check MCP server version" - echo " atelier servicectl status - View background service and systemctl status" + echo " atelier background status - View background service status" echo " atelier stack start - Start production API and Frontend (requires Docker)" echo " atelier stack stop - Stop the visualization stack" echo " atelier stack logs - View stack logs" diff --git a/src/atelier/core/capabilities/archival_recall/capability.py b/src/atelier/core/capabilities/archival_recall/capability.py index 4a67f2ed7..71ba45f41 100644 --- a/src/atelier/core/capabilities/archival_recall/capability.py +++ b/src/atelier/core/capabilities/archival_recall/capability.py @@ -24,7 +24,7 @@ def __init__(self, store: MemoryStore, embedder: Embedder, *, redactor: Callable def archive( self, *, - agent_id: str, + agent_id: str | None = None, text: str, source: ArchivalSource, source_ref: str = "", @@ -40,7 +40,7 @@ def archive( for idx, chunk in enumerate(chunks): embedding = embeddings[idx] if idx < len(embeddings) and embeddings[idx] else None passage = ArchivalPassage( - agent_id=agent_id, + agent_id=agent_id or "shared", text=chunk, embedding=embedding, embedding_model=self._embedder.name if embedding is not None else "", @@ -60,7 +60,7 @@ def archive( def recall( self, *, - agent_id: str, + agent_id: str | None, query: str, top_k: int = 5, tags: list[str] | None = None, @@ -98,7 +98,7 @@ def recall( recall_query = widened_query selected = [item.passage for item in ranked] recall = MemoryRecall( - agent_id=agent_id, + agent_id=agent_id or "shared", query=recall_query, top_passages=[passage.id for passage in selected], selected_passage_id=selected[0].id if selected else None, diff --git a/src/atelier/core/foundation/retriever.py b/src/atelier/core/foundation/retriever.py index 4028f3397..af73135d9 100644 --- a/src/atelier/core/foundation/retriever.py +++ b/src/atelier/core/foundation/retriever.py @@ -94,12 +94,16 @@ def count_reasonblock_tokens(block: ReasonBlock) -> int: return count_tokens(render_block_for_agent(block)) -def passage_in_agent_scope(passage: ArchivalPassage, requested_agent_id: str) -> bool: +def passage_in_agent_scope(passage: ArchivalPassage, requested_agent_id: str | None) -> bool: """Return whether a passage may be injected for the requested agent.""" + if requested_agent_id is None: + return True return passage.agent_id == requested_agent_id or "agent:any" in passage.tags -def filter_scoped_passages(passages: Sequence[ArchivalPassage], *, requested_agent_id: str) -> list[ArchivalPassage]: +def filter_scoped_passages( + passages: Sequence[ArchivalPassage], *, requested_agent_id: str | None +) -> list[ArchivalPassage]: """Keep only same-agent passages and explicit global lessons.""" return [passage for passage in passages if passage_in_agent_scope(passage, requested_agent_id)] diff --git a/src/atelier/core/service/api.py b/src/atelier/core/service/api.py index 99e934871..7d47921c9 100644 --- a/src/atelier/core/service/api.py +++ b/src/atelier/core/service/api.py @@ -2169,7 +2169,7 @@ def _get_mem_store() -> Any: @app.get("/v1/memory/blocks", tags=["knowledge"], dependencies=[Depends(verify_api_key)]) def memory_list_or_get( - agent_id: str, + agent_id: str | None = None, label: str | None = None, include_tombstoned: bool = False, limit: int = 200, @@ -2188,10 +2188,10 @@ def memory_upsert_block(payload: dict[str, Any]) -> Any: from atelier.infra.storage.memory_store import MemoryConcurrencyError mem = _get_mem_store() - agent_id = payload.get("agent_id") + agent_id = payload.get("agent_id") or "shared" label = payload.get("label") - if not agent_id or not label: - raise HTTPException(status_code=400, detail="agent_id and label are required") + if not label: + raise HTTPException(status_code=400, detail="label is required") existing = mem.get_block(agent_id, label) if existing is None: value = str(payload.get("value", "")) @@ -2233,10 +2233,10 @@ def memory_archive_passage(payload: dict[str, Any]) -> Any: from atelier.core.foundation.memory_models import ArchivalPassage mem = _get_mem_store() - agent_id = payload.get("agent_id") + agent_id = payload.get("agent_id") or "shared" text = payload.get("text") - if not agent_id or not text: - raise HTTPException(status_code=400, detail="agent_id and text are required") + if not text: + raise HTTPException(status_code=400, detail="text is required") valid_sources = ("trace", "block_evict", "user", "tool_output", "file_chunk") source = payload.get("source", "user") if source not in valid_sources: @@ -2258,8 +2258,8 @@ def memory_recall_passages(payload: dict[str, Any]) -> Any: mem = _get_mem_store() agent_id = payload.get("agent_id") query = payload.get("query") - if not agent_id or not query: - raise HTTPException(status_code=400, detail="agent_id and query are required") + if not query: + raise HTTPException(status_code=400, detail="query is required") since_str = payload.get("since") since_dt: datetime | None = None if since_str: diff --git a/src/atelier/gateway/adapters/cli.py b/src/atelier/gateway/adapters/cli.py index 85b22cdcd..bde1fd342 100644 --- a/src/atelier/gateway/adapters/cli.py +++ b/src/atelier/gateway/adapters/cli.py @@ -71,6 +71,21 @@ "month", ) +CONTROLLER_UNIT = "atelier-controller.service" +STACK_UNIT = "atelier-stack.service" +SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" +LAUNCHD_USER_DIR = Path.home() / "Library" / "LaunchAgents" +CONTROLLER_LABEL = "com.atelier.controller" +STACK_LABEL = "com.atelier.stack" + + +def _is_macos() -> bool: + return sys.platform == "darwin" + + +def _is_linux() -> bool: + return sys.platform.startswith("linux") + # --------------------------------------------------------------------------- # # Product telemetry helpers # @@ -608,6 +623,70 @@ def _servicectl_collect_external_analytics( return persisted +def _servicectl_check_and_apply_updates(root: Path) -> bool: + """Check for git updates and apply them if available. + + Returns True if an update was applied and the process should restart. + """ + try: + # 1. Identify project root (where .git is) + # We look for the install record or traverse up from this file. + record_path = Path.home() / ".atelier" / "install_dir" + if record_path.exists(): + project_root = Path(record_path.read_text(encoding="utf-8").strip()) + else: + # Fallback: traverse up from src/atelier/gateway/adapters/cli.py + project_root = Path(__file__).parents[4] + + if not (project_root / ".git").exists(): + return False + + # 2. git fetch + subprocess.run(["git", "fetch", "--quiet"], cwd=project_root, check=True) + + # 3. Check if behind + res = subprocess.run( + ["git", "rev-list", "HEAD..@{u}", "--count"], + cwd=project_root, + capture_output=True, + text=True, + check=True, + ) + behind_count = int(res.stdout.strip()) + + if behind_count == 0: + return False + + logger.info(f"Auto-update: detected {behind_count} new commits. Pulling...") + + # 4. Pull + subprocess.run(["git", "pull", "--ff-only", "--quiet"], cwd=project_root, check=True) + + # 5. Check if dependencies changed + if (project_root / "uv.lock").exists() or (project_root / "pyproject.toml").exists(): + import shutil + + if shutil.which("uv"): + logger.info("Auto-update: syncing dependencies with uv...") + subprocess.run(["uv", "sync"], cwd=project_root, check=True) + + # 6. Check if we should restart systemd/launchd managed services + # If we are running under systemd, we can trigger a restart of the whole stack + if os.environ.get("INVOCATION_ID"): + logger.info("Auto-update: update applied (systemd). Triggering stack restart...") + subprocess.run(["systemctl", "--user", "restart", STACK_UNIT], check=False) + elif _is_macos() and (LAUNCHD_USER_DIR / f"{STACK_LABEL}.plist").exists(): + logger.info("Auto-update: update applied (launchd). Triggering stack restart...") + subprocess.run(["launchctl", "kickstart", "-k", f"gui/{os.getuid()}/{STACK_LABEL}"], check=False) + + logger.info("Auto-update: update applied successfully. Exiting for restart.") + return True + + except Exception as exc: + logger.error(f"Auto-update failed: {exc}") + return False + + def _servicectl_tick( root: Path, *, @@ -615,6 +694,8 @@ def _servicectl_tick( session_import_interval_seconds: int, external_analytics_interval_seconds: int, external_analytics_periods: tuple[str, ...] | list[str], + auto_update: bool = False, + auto_update_interval_seconds: int = 3600, ) -> dict[str, Any]: from atelier.core.service.jobs import JOB_CONSOLIDATE_BLOCKS from atelier.core.service.worker import Worker @@ -635,6 +716,24 @@ def _servicectl_tick( now = datetime.now(UTC) state = _read_servicectl_state(root) periodic = state.setdefault("periodic_jobs", {}) + + # 0. Check for auto-updates + if auto_update: + AUTO_UPDATE_KEY = "auto_update_check" + last_update_raw = periodic.get(AUTO_UPDATE_KEY) + last_update_at: datetime | None = None + if isinstance(last_update_raw, str): + try: + last_update_at = datetime.fromisoformat(last_update_raw) + except ValueError: + last_update_at = None + + if last_update_at is None or (now - last_update_at).total_seconds() >= auto_update_interval_seconds: + if _servicectl_check_and_apply_updates(root): + # Process will exit if update was applied (Restart=always will pick it up) + sys.exit(0) + periodic[AUTO_UPDATE_KEY] = now.isoformat() + last_enqueue_raw = periodic.get(JOB_CONSOLIDATE_BLOCKS) last_enqueue_at: datetime | None = None if isinstance(last_enqueue_raw, str): @@ -2958,11 +3057,11 @@ def memory_upsert( @memory_group.command("get") -@click.option("--agent-id", required=True) +@click.option("--agent-id", default=None) @click.option("--label", required=True) @click.option("--json", "as_json", is_flag=True) @click.pass_context -def memory_get(ctx: click.Context, agent_id: str, label: str, as_json: bool) -> None: +def memory_get(ctx: click.Context, agent_id: str | None, label: str, as_json: bool) -> None: """Fetch one editable memory block.""" from atelier.infra.storage.factory import make_memory_store @@ -2974,15 +3073,15 @@ def memory_get(ctx: click.Context, agent_id: str, label: str, as_json: bool) -> if as_json: _emit(payload, as_json=True) return - click.echo(f"{payload['agent_id']}\t{payload['label']}\tv{payload['version']}") + click.echo(f"{payload.get('agent_id', 'shared')}\t{payload['label']}\tv{payload['version']}") click.echo(payload["value"]) @memory_group.command("list") -@click.option("--agent-id", required=True) +@click.option("--agent-id", default=None) @click.option("--json", "as_json", is_flag=True) @click.pass_context -def memory_list(ctx: click.Context, agent_id: str, as_json: bool) -> None: +def memory_list(ctx: click.Context, agent_id: str | None, as_json: bool) -> None: """List all memory blocks for an agent.""" from atelier.infra.storage.factory import make_memory_store @@ -2999,7 +3098,7 @@ def memory_list(ctx: click.Context, agent_id: str, as_json: bool) -> None: @memory_group.command("archive") -@click.option("--agent-id", required=True) +@click.option("--agent-id", default=None) @click.option("--text", required=True, help="Inline text or @path. Use @/dev/stdin for stdin.") @click.option("--source", required=True) @click.option("--source-ref", default="") @@ -3007,7 +3106,7 @@ def memory_list(ctx: click.Context, agent_id: str, as_json: bool) -> None: @click.pass_context def memory_archive( ctx: click.Context, - agent_id: str, + agent_id: str | None, text: str, source: str, source_ref: str, @@ -3031,7 +3130,7 @@ def memory_archive( @memory_group.command("recall") -@click.option("--agent-id", required=True) +@click.option("--agent-id", default=None) @click.option("--query", required=True) @click.option("--top-k", default=5, show_default=True, type=int) @click.option("--tags", "tag_values", multiple=True) @@ -3196,6 +3295,12 @@ def stack_group() -> None: @click.option("--with-docs", is_flag=True, help="Also start the docs site on port 3200.") def stack_start(with_docs: bool) -> None: """Start the optional visualization stack via Docker Compose.""" + if (SYSTEMD_USER_DIR / STACK_UNIT).exists(): + click.echo( + f"Notice: {STACK_UNIT} is installed. " + "Prefer using 'atelier systemd restart' or 'systemctl --user restart atelier-stack'." + ) + services = _configured_stack_services(["service", "frontend", "otel-collector"]) if with_docs: services = _configured_stack_services([*services, "docs"]) @@ -3209,12 +3314,20 @@ def stack_start(with_docs: bool) -> None: @stack_group.command("stop") def stack_stop() -> None: """Stop the optional visualization stack.""" + if (SYSTEMD_USER_DIR / STACK_UNIT).exists(): + click.echo( + f"Notice: {STACK_UNIT} is installed. " + "Prefer using 'atelier systemd uninstall' or 'systemctl --user stop atelier-stack'." + ) _run_stack_compose(["down"]) @stack_group.command("status") def stack_status() -> None: """Show visualization stack container status.""" + if (SYSTEMD_USER_DIR / STACK_UNIT).exists(): + subprocess.run(["systemctl", "--user", "status", STACK_UNIT, "--no-pager"], check=False) + click.echo("-" * 40) _run_stack_compose(["ps"]) @@ -3223,6 +3336,9 @@ def stack_status() -> None: @click.option("--with-docs", is_flag=True, help="Include docs container logs.") def stack_logs(follow: bool, with_docs: bool) -> None: """Show visualization stack logs.""" + if (SYSTEMD_USER_DIR / STACK_UNIT).exists(): + click.echo(f"Notice: {STACK_UNIT} is installed. Prefer using 'atelier systemd logs stack'.") + args = ["logs"] if follow: args.append("-f") @@ -4590,6 +4706,12 @@ def servicectl_start( ) -> None: """Start the detached background controller.""" root = ctx.obj["root"] + if (SYSTEMD_USER_DIR / CONTROLLER_UNIT).exists(): + click.echo( + f"Notice: {CONTROLLER_UNIT} is installed. " + "Prefer using 'atelier systemd restart' or 'systemctl --user restart atelier-controller'." + ) + _kill_orphan_servicectl_processes(root) status = _servicectl_status_payload(root) if status["running"]: @@ -4762,6 +4884,8 @@ def servicectl_logs(ctx: click.Context, follow: bool, lines: int) -> None: multiple=True, show_default=True, ) +@click.option("--auto-update", is_flag=True, help="Check for git updates periodically.") +@click.option("--auto-update-interval-seconds", default=3600, show_default=True, type=int) @click.pass_context def servicectl_run( ctx: click.Context, @@ -4770,6 +4894,8 @@ def servicectl_run( session_import_interval_seconds: int, external_analytics_interval_seconds: int, external_analytics_periods: tuple[str, ...], + auto_update: bool, + auto_update_interval_seconds: int, ) -> None: """Internal long-running background loop.""" root = ctx.obj["root"] @@ -4781,6 +4907,8 @@ def servicectl_run( session_import_interval_seconds=session_import_interval_seconds, external_analytics_interval_seconds=external_analytics_interval_seconds, external_analytics_periods=external_analytics_periods, + auto_update=auto_update, + auto_update_interval_seconds=auto_update_interval_seconds, ) time.sleep(max(1, interval_seconds)) except KeyboardInterrupt: @@ -4790,6 +4918,296 @@ def servicectl_run( raise SystemExit(0) from None +# ----- background services (systemd / launchd) ------------------------------ # + + +@cli.group("background") +def background_group() -> None: + """Manage Atelier background services (systemd on Linux, launchd on macOS).""" + + +@background_group.command("install") +@click.option("--with-stack", is_flag=True, help="Also install the visualization stack service.") +@click.pass_context +def background_install(ctx: click.Context, with_stack: bool) -> None: + """Install Atelier services as background units.""" + import shutil + + root = ctx.obj["root"] + project_root = _project_root() + atelier_bin = shutil.which("atelier") or str(Path(sys.argv[0]).resolve()) + + if _is_linux(): + if not shutil.which("systemctl"): + raise click.ClickException("systemctl not found.") + + SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) + + controller_content = f"""[Unit] +Description=Atelier Background Controller +After=network.target + +[Service] +Type=simple +ExecStart={atelier_bin} --root {root} servicectl run --auto-update +Restart=always +Environment=ATELIER_ROOT={root} +Environment=PYTHONUNBUFFERED=1 +WorkingDirectory={project_root} + +[Install] +WantedBy=default.target +""" + (SYSTEMD_USER_DIR / CONTROLLER_UNIT).write_text(controller_content, encoding="utf-8") + click.echo(f"Installed {CONTROLLER_UNIT}") + + if with_stack: + stack_content = f"""[Unit] +Description=Atelier Visualization Stack +After={CONTROLLER_UNIT} + +[Service] +Type=simple +WorkingDirectory={project_root} +ExecStart=docker compose up +ExecStop=docker compose down +Restart=always +Environment=ATELIER_ROOT={root} +Environment=ATELIER_STACK_ROOT={root} + +[Install] +WantedBy=default.target +""" + (SYSTEMD_USER_DIR / STACK_UNIT).write_text(stack_content, encoding="utf-8") + click.echo(f"Installed {STACK_UNIT}") + + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + subprocess.run(["systemctl", "--user", "enable", "--now", CONTROLLER_UNIT], check=True) + if with_stack: + subprocess.run(["systemctl", "--user", "enable", "--now", STACK_UNIT], check=True) + + elif _is_macos(): + LAUNCHD_USER_DIR.mkdir(parents=True, exist_ok=True) + + controller_plist = f""" + + + + Label + {CONTROLLER_LABEL} + ProgramArguments + + {atelier_bin} + --root + {root} + servicectl + run + --auto-update + + RunAtLoad + + KeepAlive + + WorkingDirectory + {project_root} + EnvironmentVariables + + ATELIER_ROOT + {root} + PYTHONUNBUFFERED + 1 + + + +""" + (LAUNCHD_USER_DIR / f"{CONTROLLER_LABEL}.plist").write_text(controller_plist, encoding="utf-8") + click.echo(f"Installed {CONTROLLER_LABEL}.plist") + + if with_stack: + stack_plist = f""" + + + + Label + {STACK_LABEL} + ProgramArguments + + docker + compose + up + + RunAtLoad + + KeepAlive + + WorkingDirectory + {project_root} + EnvironmentVariables + + ATELIER_ROOT + {root} + ATELIER_STACK_ROOT + {root} + + + +""" + (LAUNCHD_USER_DIR / f"{STACK_LABEL}.plist").write_text(stack_plist, encoding="utf-8") + click.echo(f"Installed {STACK_LABEL}.plist") + + subprocess.run(["launchctl", "load", str(LAUNCHD_USER_DIR / f"{CONTROLLER_LABEL}.plist")], check=False) + if with_stack: + subprocess.run(["launchctl", "load", str(LAUNCHD_USER_DIR / f"{STACK_LABEL}.plist")], check=False) + + else: + raise click.ClickException(f"Unsupported platform for background services: {sys.platform}") + + click.echo("Services enabled and started.") + + +@background_group.command("uninstall") +@click.pass_context +def background_uninstall(ctx: click.Context) -> None: + """Stop and remove Atelier background units.""" + if _is_linux(): + for unit in [CONTROLLER_UNIT, STACK_UNIT]: + path = SYSTEMD_USER_DIR / unit + if path.exists(): + subprocess.run(["systemctl", "--user", "disable", "--now", unit], check=False) + path.unlink() + click.echo(f"Removed {unit}") + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + + elif _is_macos(): + for label in [CONTROLLER_LABEL, STACK_LABEL]: + plist = LAUNCHD_USER_DIR / f"{label}.plist" + if plist.exists(): + subprocess.run(["launchctl", "unload", str(plist)], check=False) + plist.unlink() + click.echo(f"Removed {label}") + else: + raise click.ClickException(f"Unsupported platform: {sys.platform}") + + click.echo("Uninstallation complete.") + + +@background_group.command("status") +@click.pass_context +def background_status(ctx: click.Context) -> None: + """Show status of Atelier background units.""" + if _is_linux(): + units = [CONTROLLER_UNIT] + if (SYSTEMD_USER_DIR / STACK_UNIT).exists(): + units.append(STACK_UNIT) + for unit in units: + click.echo(f"--- {unit} ---") + subprocess.run(["systemctl", "--user", "status", unit, "--no-pager"], check=False) + click.echo("") + elif _is_macos(): + for label in [CONTROLLER_LABEL, STACK_LABEL]: + if (LAUNCHD_USER_DIR / f"{label}.plist").exists(): + click.echo(f"--- {label} ---") + subprocess.run(["launchctl", "list", label], check=False) + click.echo("") + else: + click.echo(f"Background services not supported on {sys.platform}") + + +@background_group.command("restart") +@click.pass_context +def background_restart(ctx: click.Context) -> None: + """Restart Atelier background units.""" + if _is_linux(): + units = [CONTROLLER_UNIT] + if (SYSTEMD_USER_DIR / STACK_UNIT).exists(): + units.append(STACK_UNIT) + for unit in units: + subprocess.run(["systemctl", "--user", "restart", unit], check=True) + click.echo(f"Restarted {unit}") + elif _is_macos(): + uid = os.getuid() + for label in [CONTROLLER_LABEL, STACK_LABEL]: + if (LAUNCHD_USER_DIR / f"{label}.plist").exists(): + subprocess.run(["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"], check=False) + click.echo(f"Restarted {label}") + else: + click.echo(f"Background services not supported on {sys.platform}") + + +@background_group.command("logs") +@click.argument("service", type=click.Choice(["controller", "stack"]), default="controller") +@click.option("-f", "--follow", is_flag=True, help="Follow the logs.") +@click.option("-n", "--lines", default=50, type=int, help="Number of lines to show.") +@click.pass_context +def background_logs(ctx: click.Context, service: str, follow: bool, lines: int) -> None: + """Show logs for Atelier background units.""" + if _is_linux(): + unit = CONTROLLER_UNIT if service == "controller" else STACK_UNIT + cmd = ["journalctl", "--user", "-u", unit, "-n", str(lines)] + if follow: + cmd.append("-f") + subprocess.run(cmd, check=False) + elif _is_macos(): + click.echo("macOS logs are available via Console.app or 'log show'.") + click.echo(f"Checking recently recorded stdout for {service}...") + # launchd doesn't have a built-in log viewer like journalctl. + # We'd usually rely on StandardOutPath/StandardErrorPath in the plist. + # For now, we point them to the same log files as servicectl. + log_path = _servicectl_log_path(ctx.obj["root"]) + if log_path.exists(): + cmd = ["tail", "-n", str(lines)] + if follow: + cmd.append("-f") + cmd.append(str(log_path)) + subprocess.run(cmd, check=False) + else: + click.echo(f"Logs not supported on {sys.platform}") + + +# --------------------------------------------------------------------------- # +# Alias 'systemd' to 'background' for backward compatibility # +# --------------------------------------------------------------------------- # + + +@cli.group("systemd", hidden=True) +def systemd_alias_group() -> None: + """Alias for 'background' group.""" + + +@systemd_alias_group.command("install") +@click.option("--with-stack", is_flag=True) +@click.pass_context +def systemd_install_alias(ctx: click.Context, with_stack: bool) -> None: + ctx.invoke(background_install, with_stack=with_stack) + + +@systemd_alias_group.command("uninstall") +@click.pass_context +def systemd_uninstall_alias(ctx: click.Context) -> None: + ctx.invoke(background_uninstall) + + +@systemd_alias_group.command("status") +@click.pass_context +def systemd_status_alias(ctx: click.Context) -> None: + ctx.invoke(background_status) + + +@systemd_alias_group.command("restart") +@click.pass_context +def systemd_restart_alias(ctx: click.Context) -> None: + ctx.invoke(background_restart) + + +@systemd_alias_group.command("logs") +@click.argument("service", default="controller") +@click.option("-f", "--follow", is_flag=True) +@click.option("-n", "--lines", default=50) +@click.pass_context +def systemd_logs_alias(ctx: click.Context, service: str, follow: bool, lines: int) -> None: + ctx.invoke(background_logs, service=service, follow=follow, lines=lines) + + # --------------------------------------------------------------------------- # # V3 capability commands # # --------------------------------------------------------------------------- # diff --git a/src/atelier/gateway/adapters/mcp_server.py b/src/atelier/gateway/adapters/mcp_server.py index 37f45bb80..91276092b 100644 --- a/src/atelier/gateway/adapters/mcp_server.py +++ b/src/atelier/gateway/adapters/mcp_server.py @@ -1381,14 +1381,14 @@ def _memory_upsert_block( } -def _memory_get_block(agent_id: str, label: str) -> dict[str, Any] | None: - """Fetch one editable memory block by agent and label.""" +def _memory_get_block(agent_id: str | None, label: str) -> dict[str, Any] | None: + """Retrieve a MemoryBlock by label.""" block = _memory_store().get_block(agent_id, label) return block.model_dump(mode="json") if block is not None else None def _memory_archive( - agent_id: str, + agent_id: str | None, text: str, source: str, source_ref: str = "", @@ -1406,7 +1406,7 @@ def _memory_archive( def _memory_recall( - agent_id: str, + agent_id: str | None, query: str, top_k: int = 5, tags: list[str] | None = None, @@ -1469,7 +1469,7 @@ def require(name: str, current: str | None) -> str: if op == "block_upsert": return _memory_upsert_block( - agent_id=require("agent_id", agent_id), + agent_id=agent_id or "shared", label=require("label", label), value=require("value", value), limit_chars=limit_chars, @@ -1481,10 +1481,10 @@ def require(name: str, current: str | None) -> str: actor=actor, ) if op == "block_get": - return _memory_get_block(agent_id=require("agent_id", agent_id), label=require("label", label)) + return _memory_get_block(agent_id=agent_id, label=require("label", label)) if op == "archive": return _memory_archive( - agent_id=require("agent_id", agent_id), + agent_id=agent_id, text=require("text", text), source=require("source", source), source_ref=source_ref, @@ -1492,7 +1492,7 @@ def require(name: str, current: str | None) -> str: ) if op == "recall": return _memory_recall( - agent_id=require("agent_id", agent_id), + agent_id=agent_id, query=require("query", query), top_k=top_k, tags=tags, diff --git a/src/atelier/gateway/adapters/remote_client.py b/src/atelier/gateway/adapters/remote_client.py index aae64b4d6..c35431c26 100644 --- a/src/atelier/gateway/adapters/remote_client.py +++ b/src/atelier/gateway/adapters/remote_client.py @@ -109,12 +109,10 @@ def memory(self, args: dict[str, Any]) -> dict[str, Any]: if op == "block_upsert": return self._post("/v1/memory/blocks", args) if op == "block_get": - query = urllib.parse.urlencode( - { - "agent_id": str(args.get("agent_id") or ""), - "label": str(args.get("label") or ""), - } - ) + query_params = {"label": str(args.get("label") or "")} + if args.get("agent_id"): + query_params["agent_id"] = str(args.get("agent_id")) + query = urllib.parse.urlencode(query_params) return self._get(f"/v1/memory/blocks?{query}") if op == "archive": return self._post("/v1/memory/archive", args) diff --git a/src/atelier/gateway/hosts/session_parsers/claude.py b/src/atelier/gateway/hosts/session_parsers/claude.py index fec793200..565770a1e 100644 --- a/src/atelier/gateway/hosts/session_parsers/claude.py +++ b/src/atelier/gateway/hosts/session_parsers/claude.py @@ -76,14 +76,35 @@ def _parse_ts(ts: str) -> datetime: def find_claude_sessions(root: Path | None = None) -> Iterator[tuple[str, Path]]: """Yield (workspace_slug, jsonl_path) for all Claude sessions.""" - if root is None: - root = Path("~/.claude/projects").expanduser() - if not root.is_dir(): - return - for project_dir in sorted(root.iterdir()): - if project_dir.is_dir(): - for p in project_dir.glob("*.jsonl"): - yield project_dir.name, p + if root is not None: + if not root.is_dir(): + return + roots = [root] + else: + import os + + roots = [Path("~/.claude/projects").expanduser()] + # macOS + macos_root = Path("~/Library/Application Support/claude/projects").expanduser() + if macos_root.is_dir(): + roots.append(macos_root) + # Windows + appdata = os.environ.get("APPDATA") + if appdata: + windows_root = Path(appdata) / "claude" / "projects" + if windows_root.is_dir(): + roots.append(windows_root) + + for r in roots: + if not r.is_dir(): + continue + try: + for project_dir in sorted(r.iterdir()): + if project_dir.is_dir(): + for p in project_dir.glob("*.jsonl"): + yield project_dir.name, p + except OSError: + continue def _extract_user_text(content: Any) -> str: @@ -150,19 +171,10 @@ def __init__(self, store: ContextStore) -> None: def import_all(self, root: Path | None = None, *, force: bool = False) -> list[str]: """Import all Claude sessions. Returns IDs of successfully imported traces.""" - if root is None: - root = Path("~/.claude/projects").expanduser() - if not root.is_dir(): - return [] - - all_sessions = [ - (project_dir.name, jsonl_path) - for project_dir in sorted(root.iterdir()) - if project_dir.is_dir() - for jsonl_path in sorted(project_dir.glob("*.jsonl")) - ] + all_sessions = list(find_claude_sessions(root)) total = len(all_sessions) - print(f"[atelier] claude: discovering sessions (found {total})") + if total > 0: + print(f"[atelier] claude: discovering sessions (found {total})") imported_ids = [] for i, (workspace_slug, jsonl_path) in enumerate(all_sessions): diff --git a/src/atelier/gateway/hosts/session_parsers/copilot.py b/src/atelier/gateway/hosts/session_parsers/copilot.py index d6c90ede5..1d30809bb 100644 --- a/src/atelier/gateway/hosts/session_parsers/copilot.py +++ b/src/atelier/gateway/hosts/session_parsers/copilot.py @@ -37,6 +37,7 @@ from atelier.core.foundation.redaction import redact from atelier.core.foundation.store import ContextStore from atelier.gateway.hosts.session_parsers._common import ( + _SIZE_LIMIT_BYTES, make_llm_usage_entry, summarize_usage_entries, ) @@ -205,23 +206,53 @@ def _path_within_workspace(path: str, workspace_path: str) -> bool: def find_copilot_sessions(root: Path | None = None) -> Iterator[Path]: """Yield session directories that contain an events.jsonl file.""" - roots: list[Path] + roots: list[Path] = [] if root is not None: roots = [root] else: - roots = [Path("~/.copilot/session-state").expanduser()] - for vscode_base in [ + # 1. Standalone Copilot storage + roots.append(Path("~/.copilot/session-state").expanduser()) + + # 2. VSCode workspace storage (Linux, macOS, Windows) + import os + + paths_to_check: list[Path] = [ + # Linux Path("~/.config/Code/User/workspaceStorage").expanduser(), + Path("~/.config/Code - Insiders/User/workspaceStorage").expanduser(), + # macOS Path("~/Library/Application Support/Code/User/workspaceStorage").expanduser(), - ]: + Path("~/Library/Application Support/Code - Insiders/User/workspaceStorage").expanduser(), + ] + + # Windows (using %APPDATA%) + appdata = os.environ.get("APPDATA") + if appdata: + roots.append(Path(appdata) / "github-copilot" / "session-state") + paths_to_check.append(Path(appdata) / "Code" / "User" / "workspaceStorage") + paths_to_check.append(Path(appdata) / "Code - Insiders" / "User" / "workspaceStorage") + + for vscode_base in paths_to_check: if vscode_base.is_dir(): - roots.extend(sorted(vscode_base.glob("*/GitHub.copilot-chat"))) + # Each subdirectory in workspaceStorage is a workspace hash + try: + for ws_dir in vscode_base.iterdir(): + if ws_dir.is_dir(): + chat_dir = ws_dir / "GitHub.copilot-chat" + if chat_dir.is_dir(): + roots.append(chat_dir) + except OSError: + continue + for r in roots: if not r.is_dir(): continue - for p in sorted(r.iterdir()): - if p.is_dir() and (p / "events.jsonl").exists(): - yield p + try: + for p in sorted(r.iterdir()): + if p.is_dir() and (p / "events.jsonl").exists(): + yield p + except OSError: + continue def find_copilot_transcript_files(root: Path | None = None) -> Iterator[Path]: @@ -230,49 +261,78 @@ def find_copilot_transcript_files(root: Path | None = None) -> Iterator[Path]: if root.is_dir(): yield from sorted(root.glob("*.jsonl")) return - for vscode_base in [ + + import os + + paths_to_check: list[Path] = [ + # Linux Path("~/.config/Code/User/workspaceStorage").expanduser(), + Path("~/.config/Code - Insiders/User/workspaceStorage").expanduser(), + # macOS Path("~/Library/Application Support/Code/User/workspaceStorage").expanduser(), - ]: + Path("~/Library/Application Support/Code - Insiders/User/workspaceStorage").expanduser(), + ] + + # Windows + appdata = os.environ.get("APPDATA") + if appdata: + paths_to_check.append(Path(appdata) / "Code" / "User" / "workspaceStorage") + paths_to_check.append(Path(appdata) / "Code - Insiders" / "User" / "workspaceStorage") + + for vscode_base in paths_to_check: if not vscode_base.is_dir(): continue - for ws in sorted(vscode_base.iterdir()): - transcript_dir = ws / "GitHub.copilot-chat" / "transcripts" - if transcript_dir.is_dir(): - yield from sorted(transcript_dir.glob("*.jsonl")) + try: + for ws in sorted(vscode_base.iterdir()): + transcript_dir = ws / "GitHub.copilot-chat" / "transcripts" + if transcript_dir.is_dir(): + yield from sorted(transcript_dir.glob("*.jsonl")) + except OSError: + continue def find_copilot_debug_log_dirs(root: Path | None = None) -> Iterator[Path]: - """Yield per-session debug-log directories from VSCode Copilot Chat. - - Each directory ``debug-logs//`` contains ``main.jsonl`` plus - ``runSubagent-*.jsonl`` and ``title-*.jsonl`` files. They hold the only - per-LLM-request token telemetry (events of ``type:"llm_request"`` with - ``attrs.model / inputTokens / outputTokens``) that VSCode Copilot Chat - exposes — the sibling ``transcripts/.jsonl`` carries no token data. - - Without this source atelier under-reports VSCode Copilot Chat activity by - several hundred LLM calls per active day. - """ + """Yield per-session debug-log directories from VSCode Copilot Chat.""" if root is not None: if root.is_dir(): - for sid_dir in sorted(root.iterdir()): - if sid_dir.is_dir() and (sid_dir / "main.jsonl").exists(): - yield sid_dir + try: + for sid_dir in sorted(root.iterdir()): + if sid_dir.is_dir() and (sid_dir / "main.jsonl").exists(): + yield sid_dir + except OSError: + pass return - for vscode_base in [ + + import os + + paths_to_check: list[Path] = [ + # Linux Path("~/.config/Code/User/workspaceStorage").expanduser(), + Path("~/.config/Code - Insiders/User/workspaceStorage").expanduser(), + # macOS Path("~/Library/Application Support/Code/User/workspaceStorage").expanduser(), - ]: + Path("~/Library/Application Support/Code - Insiders/User/workspaceStorage").expanduser(), + ] + + # Windows + appdata = os.environ.get("APPDATA") + if appdata: + paths_to_check.append(Path(appdata) / "Code" / "User" / "workspaceStorage") + paths_to_check.append(Path(appdata) / "Code - Insiders" / "User" / "workspaceStorage") + + for vscode_base in paths_to_check: if not vscode_base.is_dir(): continue - for ws in sorted(vscode_base.iterdir()): - debug_root = ws / "GitHub.copilot-chat" / "debug-logs" - if not debug_root.is_dir(): - continue - for sid_dir in sorted(debug_root.iterdir()): - if sid_dir.is_dir() and (sid_dir / "main.jsonl").exists(): - yield sid_dir + try: + for ws in sorted(vscode_base.iterdir()): + debug_root = ws / "GitHub.copilot-chat" / "debug-logs" + if not debug_root.is_dir(): + continue + for sid_dir in sorted(debug_root.iterdir()): + if sid_dir.is_dir() and (sid_dir / "main.jsonl").exists(): + yield sid_dir + except OSError: + continue # --------------------------------------------------------------------------- @@ -311,14 +371,18 @@ def import_all(self, root: Path | None = None, *, force: bool = False) -> list[s all_debug_logs = list(find_copilot_debug_log_dirs(root)) total = len(all_sessions) + len(all_transcripts) + len(all_debug_logs) print( - "[atelier] copilot: discovering sessions " - f"(found {len(all_sessions)} directory, {len(all_transcripts)} transcript, " - f"{len(all_debug_logs)} debug-log)" + f"[atelier] copilot: found {len(all_sessions)} session directories, " + f"{len(all_transcripts)} transcript files, {len(all_debug_logs)} debug-log directories" ) - for i, session_dir in enumerate(all_sessions): + + processed = 0 + + # Phase 1: Session Directories (the primary source) + for session_dir in all_sessions: + processed += 1 + if processed % 10 == 0: + print(f"[atelier] copilot: importing {processed}/{total} (sessions)...") try: - if i % 10 == 0 and i > 0: - print(f"[atelier] copilot: importing {i}/{total}...") sid = self.import_session(session_dir, force=force) if sid: imported_ids.append(sid) @@ -327,9 +391,18 @@ def import_all(self, root: Path | None = None, *, force: bool = False) -> list[s except Exception as exc: _traceback.print_exc() print(f"[atelier] skipping session {session_dir.name}: {exc}") + + # Pre-index parent traces and workspaces to avoid O(N^2) lookups during transcript linking + # This is a major optimization for large history imports. + parent_index = self._build_parent_index() + + # Phase 2: Transcript Files (VSCode-specific chat history) for transcript_path in all_transcripts: + processed += 1 + if processed % 10 == 0: + print(f"[atelier] copilot: importing {processed}/{total} (transcripts)...") try: - sid = self.import_transcript_file(transcript_path, force=force) + sid = self.import_transcript_file(transcript_path, force=force, parent_index=parent_index) if sid: imported_ids.append(sid) else: @@ -337,7 +410,12 @@ def import_all(self, root: Path | None = None, *, force: bool = False) -> list[s except Exception as exc: _traceback.print_exc() print(f"[atelier] skipping transcript {transcript_path.name}: {exc}") + + # Phase 3: Debug Log Directories (telemetry/token counts) for debug_log_dir in all_debug_logs: + processed += 1 + if processed % 10 == 0: + print(f"[atelier] copilot: importing {processed}/{total} (debug-logs)...") try: sid = self.import_debug_log_dir(debug_log_dir, force=force) if sid: @@ -347,16 +425,57 @@ def import_all(self, root: Path | None = None, *, force: bool = False) -> list[s except Exception as exc: _traceback.print_exc() print(f"[atelier] skipping debug-log {debug_log_dir.name}: {exc}") - for sid in self._reconcile_stored_transcripts(): + + # Phase 4: Reconciliation (link existing orphans) + reconciled = self._reconcile_stored_transcripts(parent_index=parent_index) + for sid in reconciled: if sid not in imported_ids: imported_ids.append(sid) + if skipped > 0: - print(f"[atelier] {skipped} sessions already imported (skipped by dedup)") + print(f"[atelier] {skipped} copilot artifacts already imported (skipped by dedup)") return imported_ids - def _reconcile_stored_transcripts(self) -> list[str]: + def _build_parent_index(self) -> list[dict[str, Any]]: + """Pre-index parent traces and their workspace roots for efficient transcript linking.""" + index = [] + # We need Trace objects for their created_at and session_id + traces = { + t.session_id: t + for t in self.store.list_traces(host="copilot", limit=10_000) + if t.session_id and not t.id.startswith("copilot-transcript-") + } + + # We need workspace.yaml artifacts for their CWD + artifacts = self.store.list_raw_artifacts(source="copilot", limit=10_000) + for art in artifacts: + if art.kind != "workspace.yaml": + continue + parent_trace = traces.get(art.source_session_id) + if not parent_trace: + continue + + try: + content = self.store.read_raw_artifact_content(art) + workspace_data = yaml.safe_load(content) or {} + cwd = _text_from_value(workspace_data.get("cwd")) + if cwd: + index.append( + { + "trace": parent_trace, + "cwd": cwd, + "normalized_cwd": _normalize_match_path(cwd).casefold(), + } + ) + except Exception: + continue + return index + + def _reconcile_stored_transcripts(self, parent_index: list[dict[str, Any]] | None = None) -> list[str]: imported_ids: list[str] = [] artifacts = self.store.list_raw_artifacts(source="copilot", limit=10_000) + p_index = parent_index if parent_index is not None else self._build_parent_index() + for artifact in artifacts: if not artifact.content_path.startswith("raw/copilot/transcripts/"): continue @@ -375,6 +494,7 @@ def _reconcile_stored_transcripts(self) -> list[str]: session_id=session_id, redacted_events=redacted_events, artifact_id=artifact.id, + parent_index=p_index, ) if sid: imported_ids.append(sid) @@ -612,6 +732,20 @@ def import_session(self, session_dir: Path, *, force: bool = False) -> str | Non """Import a single session directory. Returns trace ID on success.""" session_id = session_dir.name + # --- events --- + events_path = session_dir / "events.jsonl" + if not events_path.exists(): + return None + + # Size check for massive sessions + try: + size = events_path.stat().st_size + if size > _SIZE_LIMIT_BYTES: + print(f"[atelier] copilot: skipping massive session {session_id} ({size / 1e6:.1f}MB)") + return None + except OSError: + pass + # ── Timestamp-based dedup check ────────────────────────────── artifact_id = f"copilot-{session_id}-events-jsonl" existing = self.store.get_raw_artifact(artifact_id) @@ -724,35 +858,21 @@ def _find_parent_trace_for_transcript( self, transcript_paths: set[str], transcript_started_at: datetime | None, + parent_index: list[dict[str, Any]] | None = None, ) -> tuple[Trace, str] | None: if transcript_started_at is None or not transcript_paths: return None - traces_by_session_id = { - trace.session_id: trace - for trace in self.store.list_traces(host="copilot", limit=5000) - if trace.session_id and not trace.id.startswith("copilot-transcript-") - } + p_index = parent_index if parent_index is not None else self._build_parent_index() max_delta_seconds = _MAX_TRANSCRIPT_PARENT_DELTA.total_seconds() best_match: tuple[tuple[int, float], Trace, str] | None = None - for artifact in self.store.list_raw_artifacts(source="copilot", limit=5000): - if artifact.kind != "workspace.yaml": - continue - - parent_trace = traces_by_session_id.get(artifact.source_session_id) - if parent_trace is None: - continue - - try: - workspace_data = yaml.safe_load(self.store.read_raw_artifact_content(artifact)) or {} - except (OSError, yaml.YAMLError): - continue + for entry in p_index: + parent_trace = entry["trace"] + workspace_cwd = entry["cwd"] + normalized_cwd = entry["normalized_cwd"] - workspace_cwd = _text_from_value(workspace_data.get("cwd")) - if not workspace_cwd: - continue if not any(_path_within_workspace(path, workspace_cwd) for path in transcript_paths): continue @@ -760,7 +880,7 @@ def _find_parent_trace_for_transcript( if delta_seconds > max_delta_seconds: continue - score = (len(_normalize_match_path(workspace_cwd)), -delta_seconds) + score = (len(normalized_cwd), -delta_seconds) if best_match is None or score > best_match[0]: best_match = (score, parent_trace, workspace_cwd) @@ -768,10 +888,21 @@ def _find_parent_trace_for_transcript( return None return best_match[1], best_match[2] - def import_transcript_file(self, transcript_path: Path, *, force: bool = False) -> str | None: + def import_transcript_file( + self, transcript_path: Path, *, force: bool = False, parent_index: list[dict[str, Any]] | None = None + ) -> str | None: """Import a single VSCode Copilot transcript .jsonl file.""" session_id = transcript_path.stem + # Size check + try: + size = transcript_path.stat().st_size + if size > _SIZE_LIMIT_BYTES: + print(f"[atelier] copilot: skipping massive transcript {session_id} ({size / 1e6:.1f}MB)") + return None + except OSError: + pass + artifact_id = f"copilot-transcript-{session_id}" existing = self.store.get_raw_artifact(artifact_id) try: @@ -815,6 +946,7 @@ def import_transcript_file(self, transcript_path: Path, *, force: bool = False) session_id=session_id, redacted_events=redacted_events, artifact_id=artifact_id, + parent_index=parent_index, ) # ------------------------------------------------------------------ @@ -1003,9 +1135,18 @@ def import_debug_log_dir(self, debug_log_dir: Path, *, force: bool = False) -> s return last_trace_id - def _materialize_transcript_trace(self, *, session_id: str, redacted_events: str, artifact_id: str) -> str | None: + def _materialize_transcript_trace( + self, + *, + session_id: str, + redacted_events: str, + artifact_id: str, + parent_index: list[dict[str, Any]] | None = None, + ) -> str | None: transcript_paths, transcript_started_at = _extract_transcript_linkage(redacted_events) - parent_match = self._find_parent_trace_for_transcript(transcript_paths, transcript_started_at) + parent_match = self._find_parent_trace_for_transcript( + transcript_paths, transcript_started_at, parent_index=parent_index + ) if parent_match is None: self.store.delete_trace(artifact_id) return None diff --git a/src/atelier/gateway/hosts/session_parsers/cursor.py b/src/atelier/gateway/hosts/session_parsers/cursor.py index c6646409a..fd99f8498 100644 --- a/src/atelier/gateway/hosts/session_parsers/cursor.py +++ b/src/atelier/gateway/hosts/session_parsers/cursor.py @@ -27,7 +27,27 @@ def _db_path(root: Path | None = None) -> Path: if root is not None: return root - return Path.home() / ".config" / "Cursor" / "User" / "globalStorage" / "state.vscdb" + + import os + import sys + + # Linux + linux_path = Path.home() / ".config" / "Cursor" / "User" / "globalStorage" / "state.vscdb" + # macOS + macos_path = Path.home() / "Library" / "Application Support" / "Cursor" / "User" / "globalStorage" / "state.vscdb" + # Windows + appdata = os.environ.get("APPDATA") + windows_path = Path(appdata) / "Cursor" / "User" / "globalStorage" / "state.vscdb" if appdata else None + + if sys.platform == "darwin" and macos_path.exists(): + return macos_path + if sys.platform == "win32" and windows_path and windows_path.exists(): + return windows_path + if linux_path.exists(): + return linux_path + + # Fallback to linux/default if nothing found + return linux_path def _workspace_storage_dir(db_path: Path) -> Path: diff --git a/src/atelier/infra/memory_bridges/letta_adapter.py b/src/atelier/infra/memory_bridges/letta_adapter.py index 1cf557f9c..1a1d3f241 100644 --- a/src/atelier/infra/memory_bridges/letta_adapter.py +++ b/src/atelier/infra/memory_bridges/letta_adapter.py @@ -383,23 +383,32 @@ def upsert_block(self, block: MemoryBlock, *, actor: str, reason: str = "") -> M data = self._adapter.upsert_block(block) return LettaAdapter.letta_to_block(data or LettaAdapter.block_to_letta(block), agent_id=block.agent_id) - def get_block(self, agent_id: str, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: - data = self._adapter.get_block(agent_id, label) + def get_block(self, agent_id: str | None, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: + target_agent = agent_id or "default" + data = self._adapter.get_block(target_agent, label) if data is not None: - block = LettaAdapter.letta_to_block(data, agent_id=agent_id) + block = LettaAdapter.letta_to_block(data, agent_id=target_agent) if block.deprecated_at is not None and not include_tombstoned: return None return block return None - def list_blocks(self, agent_id: str, *, include_tombstoned: bool = False, limit: int = 500) -> list[MemoryBlock]: - blocks = [LettaAdapter.letta_to_block(item, agent_id=agent_id) for item in self._adapter.list_blocks(agent_id)] + def list_blocks( + self, agent_id: str | None, *, include_tombstoned: bool = False, limit: int = 500 + ) -> list[MemoryBlock]: + target_agent = agent_id or "default" + blocks = [ + LettaAdapter.letta_to_block(item, agent_id=target_agent) for item in self._adapter.list_blocks(target_agent) + ] if not include_tombstoned: blocks = [block for block in blocks if block.deprecated_at is None] return blocks[:limit] - def list_pinned_blocks(self, agent_id: str) -> list[MemoryBlock]: - blocks = [LettaAdapter.letta_to_block(item, agent_id=agent_id) for item in self._adapter.list_blocks(agent_id)] + def list_pinned_blocks(self, agent_id: str | None) -> list[MemoryBlock]: + target_agent = agent_id or "default" + blocks = [ + LettaAdapter.letta_to_block(item, agent_id=target_agent) for item in self._adapter.list_blocks(target_agent) + ] pinned = [block for block in blocks if block.pinned] return [block for block in pinned if block.deprecated_at is None] @@ -434,7 +443,7 @@ def insert_passage(self, passage: ArchivalPassage) -> ArchivalPassage: def search_passages( self, - agent_id: str, + agent_id: str | None, query: str, *, top_k: int = 5, @@ -442,7 +451,7 @@ def search_passages( since: datetime | None = None, ) -> list[ArchivalPassage]: results = self._adapter.search_archival( - agent_id=agent_id, + agent_id=agent_id or "default", query=query, top_k=top_k, tags=tags, @@ -453,21 +462,21 @@ def search_passages( text = str(item.get("text", item.get("value", ""))) if not text: continue - passage = LettaAdapter.letta_to_passage(item, agent_id=agent_id) + passage = LettaAdapter.letta_to_passage(item, agent_id=agent_id or "default") if passage is not None: passages.append(passage) return passages[:top_k] def list_passages( self, - agent_id: str, + agent_id: str | None, *, tags: list[str] | None = None, since: datetime | None = None, limit: int = 200, ) -> list[ArchivalPassage]: - rows = self._adapter.list_archival(agent_id=agent_id, tags=tags, since=since, limit=limit) - passages = [LettaAdapter.letta_to_passage(row, agent_id=agent_id) for row in rows] + rows = self._adapter.list_archival(agent_id=agent_id or "default", tags=tags, since=since, limit=limit) + passages = [LettaAdapter.letta_to_passage(row, agent_id=agent_id or "default") for row in rows] return [passage for passage in passages if passage is not None] def record_recall(self, recall: MemoryRecall) -> MemoryRecall: @@ -478,7 +487,7 @@ def record_recall(self, recall: MemoryRecall) -> MemoryRecall: ) return self._recall_store.record_recall(recall) - def list_recalls(self, agent_id: str, *, limit: int = 50) -> list[MemoryRecall]: + def list_recalls(self, agent_id: str | None, *, limit: int = 50) -> list[MemoryRecall]: return self._recall_store.list_recalls(agent_id, limit=limit) def write_run_frame(self, frame: RunMemoryFrame) -> None: diff --git a/src/atelier/infra/memory_bridges/openmemory.py b/src/atelier/infra/memory_bridges/openmemory.py index f7afcf01c..3646b4f82 100644 --- a/src/atelier/infra/memory_bridges/openmemory.py +++ b/src/atelier/infra/memory_bridges/openmemory.py @@ -68,13 +68,15 @@ def db_path(self) -> Path: def upsert_block(self, block: MemoryBlock, *, actor: str, reason: str = "") -> MemoryBlock: return self._store.upsert_block(block, actor=actor, reason=reason) - def get_block(self, agent_id: str, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: + def get_block(self, agent_id: str | None, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: return self._store.get_block(agent_id, label, include_tombstoned=include_tombstoned) - def list_blocks(self, agent_id: str, *, include_tombstoned: bool = False, limit: int = 500) -> list[MemoryBlock]: + def list_blocks( + self, agent_id: str | None, *, include_tombstoned: bool = False, limit: int = 500 + ) -> list[MemoryBlock]: return self._store.list_blocks(agent_id, include_tombstoned=include_tombstoned, limit=limit) - def list_pinned_blocks(self, agent_id: str) -> list[MemoryBlock]: + def list_pinned_blocks(self, agent_id: str | None) -> list[MemoryBlock]: return self._store.list_pinned_blocks(agent_id) def list_block_history(self, block_id: str, *, limit: int = 50) -> list[MemoryBlockHistory]: @@ -100,7 +102,7 @@ def insert_passage(self, passage: ArchivalPassage) -> ArchivalPassage: def search_passages( self, - agent_id: str, + agent_id: str | None, query: str, *, top_k: int = 5, @@ -108,12 +110,12 @@ def search_passages( since: datetime | None = None, ) -> list[ArchivalPassage]: with contextlib.suppress(Exception): - self._adapter.fetch_context(task=query, project_id=agent_id) + self._adapter.fetch_context(task=query, project_id=agent_id or "default") return self._store.search_passages(agent_id, query, top_k=top_k, tags=tags, since=since) def list_passages( self, - agent_id: str, + agent_id: str | None, *, tags: list[str] | None = None, since: datetime | None = None, @@ -124,7 +126,7 @@ def list_passages( def record_recall(self, recall: MemoryRecall) -> MemoryRecall: return self._store.record_recall(recall) - def list_recalls(self, agent_id: str, *, limit: int = 50) -> list[MemoryRecall]: + def list_recalls(self, agent_id: str | None, *, limit: int = 50) -> list[MemoryRecall]: return self._store.list_recalls(agent_id, limit=limit) def write_run_frame(self, frame: RunMemoryFrame) -> None: diff --git a/src/atelier/infra/storage/memory_store.py b/src/atelier/infra/storage/memory_store.py index 3106a3460..653fcbce0 100644 --- a/src/atelier/infra/storage/memory_store.py +++ b/src/atelier/infra/storage/memory_store.py @@ -24,11 +24,13 @@ class MemorySidecarUnavailable(RuntimeError): class MemoryStore(Protocol): def upsert_block(self, block: MemoryBlock, *, actor: str, reason: str = "") -> MemoryBlock: ... - def get_block(self, agent_id: str, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: ... + def get_block( + self, agent_id: str | None, label: str, *, include_tombstoned: bool = False + ) -> MemoryBlock | None: ... def list_blocks( - self, agent_id: str, *, include_tombstoned: bool = False, limit: int = 500 + self, agent_id: str | None, *, include_tombstoned: bool = False, limit: int = 500 ) -> list[MemoryBlock]: ... - def list_pinned_blocks(self, agent_id: str) -> list[MemoryBlock]: ... + def list_pinned_blocks(self, agent_id: str | None) -> list[MemoryBlock]: ... def list_block_history(self, block_id: str, *, limit: int = 50) -> list[MemoryBlockHistory]: ... def delete_block(self, block_id: str) -> None: ... def tombstone_block( @@ -38,7 +40,7 @@ def tombstone_block( def insert_passage(self, passage: ArchivalPassage) -> ArchivalPassage: ... def search_passages( self, - agent_id: str, + agent_id: str | None, query: str, *, top_k: int = 5, @@ -47,14 +49,14 @@ def search_passages( ) -> list[ArchivalPassage]: ... def list_passages( self, - agent_id: str, + agent_id: str | None, *, tags: list[str] | None = None, since: datetime | None = None, limit: int = 200, ) -> list[ArchivalPassage]: ... def record_recall(self, recall: MemoryRecall) -> MemoryRecall: ... - def list_recalls(self, agent_id: str, *, limit: int = 50) -> list[MemoryRecall]: ... + def list_recalls(self, agent_id: str | None, *, limit: int = 50) -> list[MemoryRecall]: ... def write_run_frame(self, frame: RunMemoryFrame) -> None: ... def get_run_frame(self, session_id: str) -> RunMemoryFrame | None: ... diff --git a/src/atelier/infra/storage/sqlite_memory_store.py b/src/atelier/infra/storage/sqlite_memory_store.py index 483bc5f81..04f5bc440 100644 --- a/src/atelier/infra/storage/sqlite_memory_store.py +++ b/src/atelier/infra/storage/sqlite_memory_store.py @@ -184,38 +184,58 @@ def upsert_block(self, block: MemoryBlock, *, actor: str, reason: str = "") -> M raise RuntimeError("memory block upsert did not persist") return stored - def get_block(self, agent_id: str, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: + def get_block(self, agent_id: str | None, label: str, *, include_tombstoned: bool = False) -> MemoryBlock | None: + params: list[Any] = [label] + agent_sql = "1=1" + if agent_id is not None: + agent_sql = "agent_id = ?" + params.append(agent_id) + tombstone_sql = "" if include_tombstoned else " AND deprecated_at IS NULL" with self._store._connect() as conn: row = conn.execute( - f"SELECT * FROM memory_block WHERE agent_id = ? AND label = ?{tombstone_sql}", - (agent_id, label), + f"SELECT * FROM memory_block WHERE label = ? AND {agent_sql}{tombstone_sql}", + params, ).fetchone() return self._block_from_row(row) if row is not None else None - def list_blocks(self, agent_id: str, *, include_tombstoned: bool = False, limit: int = 500) -> list[MemoryBlock]: + def list_blocks( + self, agent_id: str | None, *, include_tombstoned: bool = False, limit: int = 500 + ) -> list[MemoryBlock]: + params: list[Any] = [] + agent_sql = "1=1" + if agent_id is not None: + agent_sql = "agent_id = ?" + params.append(agent_id) + tombstone_sql = "" if include_tombstoned else " AND deprecated_at IS NULL" with self._store._connect() as conn: rows = conn.execute( f""" SELECT * FROM memory_block - WHERE agent_id = ?{tombstone_sql} + WHERE {agent_sql}{tombstone_sql} ORDER BY updated_at DESC LIMIT ? """, - (agent_id, limit), + (*params, limit), ).fetchall() return [self._block_from_row(row) for row in rows] - def list_pinned_blocks(self, agent_id: str) -> list[MemoryBlock]: + def list_pinned_blocks(self, agent_id: str | None) -> list[MemoryBlock]: + params: list[Any] = [] + agent_sql = "1=1" + if agent_id is not None: + agent_sql = "agent_id = ?" + params.append(agent_id) + with self._store._connect() as conn: rows = conn.execute( - """ + f""" SELECT * FROM memory_block - WHERE agent_id = ? AND pinned = 1 AND deprecated_at IS NULL + WHERE {agent_sql} AND pinned = 1 AND deprecated_at IS NULL ORDER BY updated_at DESC """, - (agent_id,), + params, ).fetchall() return [self._block_from_row(row) for row in rows] @@ -302,7 +322,7 @@ def insert_passage(self, passage: ArchivalPassage) -> ArchivalPassage: def search_passages( self, - agent_id: str, + agent_id: str | None, query: str, *, top_k: int = 5, @@ -318,13 +338,18 @@ def search_passages( def list_passages( self, - agent_id: str, + agent_id: str | None, *, tags: list[str] | None = None, since: datetime | None = None, limit: int = 200, ) -> list[ArchivalPassage]: - params: list[Any] = [agent_id] + params: list[Any] = [] + agent_sql = "1=1" + if agent_id is not None: + agent_sql = "(agent_id = ? OR tags LIKE '%\"agent:any\"%')" + params.append(agent_id) + since_sql = "" if since is not None: since_sql = " AND created_at >= ?" @@ -333,7 +358,7 @@ def list_passages( rows = conn.execute( f""" SELECT * FROM archival_passage - WHERE agent_id = ?{since_sql} + WHERE {agent_sql}{since_sql} ORDER BY created_at DESC LIMIT ? """, @@ -364,16 +389,22 @@ def record_recall(self, recall: MemoryRecall) -> MemoryRecall: ) return recall - def list_recalls(self, agent_id: str, *, limit: int = 50) -> list[MemoryRecall]: + def list_recalls(self, agent_id: str | None, *, limit: int = 50) -> list[MemoryRecall]: + params: list[Any] = [] + agent_sql = "1=1" + if agent_id is not None: + agent_sql = "agent_id = ?" + params.append(agent_id) + with self._store._connect() as conn: rows = conn.execute( - """ + f""" SELECT * FROM memory_recall - WHERE agent_id = ? + WHERE {agent_sql} ORDER BY created_at DESC LIMIT ? """, - (agent_id, limit), + (*params, limit), ).fetchall() return [ MemoryRecall( @@ -439,13 +470,18 @@ def get_run_frame(self, session_id: str) -> RunMemoryFrame | None: def _search_passage_rows( self, - agent_id: str, + agent_id: str | None, query: str, *, top_k: int, since: datetime | None, ) -> list[sqlite3.Row]: - params: list[Any] = [agent_id] + params: list[Any] = [] + agent_sql = "1=1" + if agent_id is not None: + agent_sql = "(p.agent_id = ? OR p.tags LIKE '%\"agent:any\"%')" + params.append(agent_id) + since_sql = "" if since is not None: since_sql = " AND p.created_at >= ?" @@ -458,7 +494,7 @@ def _search_passage_rows( f""" SELECT p.* FROM archival_passage_fts f JOIN archival_passage p ON p.rowid = f.rowid - WHERE p.agent_id = ?{since_sql} AND archival_passage_fts MATCH ? + WHERE {agent_sql}{since_sql} AND archival_passage_fts MATCH ? ORDER BY bm25(archival_passage_fts), p.created_at DESC LIMIT ? """, @@ -467,7 +503,7 @@ def _search_passage_rows( return conn.execute( f""" SELECT p.* FROM archival_passage p - WHERE p.agent_id = ?{since_sql} + WHERE {agent_sql}{since_sql} ORDER BY p.created_at DESC LIMIT ? """, diff --git a/tests/gateway/test_mcp_memory_tools.py b/tests/gateway/test_mcp_memory_tools.py index 996623a99..cd6f42200 100644 --- a/tests/gateway/test_mcp_memory_tools.py +++ b/tests/gateway/test_mcp_memory_tools.py @@ -38,6 +38,8 @@ def _memory_args(op: str, **kwargs: Any) -> dict[str, Any]: def mcp_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: root = tmp_path / ".atelier" monkeypatch.setenv("ATELIER_ROOT", str(root)) + monkeypatch.setenv("ATELIER_DEV_MODE", "1") + monkeypatch.setattr(mcp_server, "_REMOTE_TOOLS", frozenset()) mcp_server._current_ledger = None mcp_server._realtime_ctx = None return root @@ -77,6 +79,7 @@ def test_memory_upsert_and_get_round_trip(mcp_root: Path) -> None: def test_memory_get_returns_null_on_miss(mcp_root: Path) -> None: _ = mcp_root + # Updated: it returns None or null if missing, not an error payload assert _payload(_call("memory", _memory_args("block_get", agent_id="atelier:code", label="missing"))) is None