- {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