Skip to content

Instantly share code, notes, and snippets.

@krisanalfa
Created May 22, 2026 09:15
Show Gist options
  • Select an option

  • Save krisanalfa/db119b7716db3de6d3ce3f62e4ad45f0 to your computer and use it in GitHub Desktop.

Select an option

Save krisanalfa/db119b7716db3de6d3ce3f62e4ad45f0 to your computer and use it in GitHub Desktop.
MemPalace + VS Code Copilot MCP and Hook Setup
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Iterable
DEFAULT_STORAGE_ROOT = Path.home() / ".config" / "Code" / "User" / "workspaceStorage"
DEFAULT_OUTPUT_ROOT = Path.home() / ".mempalace" / "vscode-copilot-transcripts"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert VS Code Copilot transcripts into MemPalace-friendly transcripts and mine them."
)
parser.add_argument("path", nargs="?", help="Transcript file or transcript directory. Defaults to VS Code workspaceStorage discovery.")
parser.add_argument("--transcript-path", help="Explicit transcript JSON/JSONL file.")
parser.add_argument("--session-id", help="Copilot chat session ID used to locate a transcript file.")
parser.add_argument(
"--workspace-storage-dir",
default=os.environ.get("VSCODE_WORKSPACE_STORAGE_DIR", str(DEFAULT_STORAGE_ROOT)),
help="Root VS Code workspaceStorage directory.",
)
parser.add_argument(
"--output-dir",
default=os.environ.get("MEMPAL_VSCODE_OUTPUT_DIR", str(DEFAULT_OUTPUT_ROOT)),
help="Directory for converted plain-text transcripts.",
)
parser.add_argument("--wing", default=os.environ.get("MEMPAL_VSCODE_WING", "vscode_copilot"))
parser.add_argument("--agent", default=os.environ.get("MEMPAL_VSCODE_AGENT", "mempalace"))
parser.add_argument("--extract", choices=["exchange", "general"], default="exchange")
parser.add_argument("--convert-only", action="store_true", help="Write converted transcripts without invoking MemPalace.")
parser.add_argument("--dry-run", action="store_true", help="Pass --dry-run to mempalace mine.")
parser.add_argument("--verbose", action="store_true", help="Print converted file paths.")
return parser.parse_args()
def discover_transcript_path(session_id: str | None, storage_root: Path) -> Path | None:
if not storage_root.is_dir():
return None
if session_id and session_id != "unknown":
for suffix in (".jsonl", ".json"):
matches = list(storage_root.glob(f"**/GitHub.copilot-chat/transcripts/{session_id}{suffix}"))
if matches:
return matches[0]
candidates = sorted(
(
path
for path in storage_root.glob("**/GitHub.copilot-chat/transcripts/*")
if path.is_file() and path.suffix in {".json", ".jsonl"}
),
key=lambda path: path.stat().st_mtime,
reverse=True,
)
return candidates[0] if candidates else None
def resolve_inputs(args: argparse.Namespace) -> list[Path]:
explicit = args.transcript_path or args.path
if explicit:
candidate = Path(explicit).expanduser()
if candidate.is_file():
return [candidate.resolve()]
if candidate.is_dir():
return sorted(
path.resolve()
for path in candidate.iterdir()
if path.is_file() and path.suffix in {".json", ".jsonl"}
)
raise FileNotFoundError(f"Transcript path not found: {candidate}")
discovered = discover_transcript_path(args.session_id, Path(args.workspace_storage_dir).expanduser())
if discovered is None:
raise FileNotFoundError("Could not locate any VS Code Copilot transcript files")
return [discovered.resolve()]
def normalize_text(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
parts: list[str] = []
for item in value:
text = normalize_text(item)
if text:
parts.append(text)
return "\n".join(parts).strip()
if isinstance(value, dict):
for key in ("text", "content", "body", "markdown"):
text = normalize_text(value.get(key))
if text:
return text
return ""
def append_message(messages: list[list[str]], role: str, text: str) -> None:
if not text:
return
if messages and messages[-1][0] == role:
previous = messages[-1][1]
if text == previous or text in previous.split("\n\n"):
return
if text.startswith(previous):
messages[-1][1] = text
return
if previous.startswith(text):
return
separator = "\n\n" if role == "assistant" else "\n"
messages[-1][1] = previous + separator + text
return
messages.append([role, text])
def convert_transcript(source_path: Path) -> tuple[str | None, str | None]:
messages: list[list[str]] = []
session_id: str | None = None
with source_path.open("r", encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
entry_type = entry.get("type")
data = entry.get("data") or {}
if not session_id and isinstance(data, dict):
session_id = data.get("sessionId") or session_id
if entry_type == "user.message":
append_message(messages, "user", normalize_text(data.get("content")))
elif entry_type == "assistant.message":
append_message(messages, "assistant", normalize_text(data.get("content")))
if len(messages) < 2:
return session_id, None
lines: list[str] = []
index = 0
while index < len(messages):
role, text = messages[index]
if role == "user":
lines.append(f"> {text}")
if index + 1 < len(messages) and messages[index + 1][0] == "assistant":
lines.append(messages[index + 1][1])
index += 2
else:
index += 1
else:
lines.append(text)
index += 1
lines.append("")
provenance = f"[source: vscode-copilot-chat | session: {session_id or source_path.stem}]"
lines.append(provenance)
return session_id, "\n".join(lines).strip() + "\n"
def write_converted_transcript(source_path: Path, output_root: Path) -> Path | None:
session_id, transcript = convert_transcript(source_path)
if not transcript:
return None
output_root.mkdir(parents=True, exist_ok=True)
output_name = f"{session_id or source_path.stem}.txt"
output_path = output_root / output_name
output_path.write_text(transcript, encoding="utf-8")
try:
stat = source_path.stat()
os.utime(output_path, (stat.st_atime, stat.st_mtime))
except OSError:
pass
return output_path
def run_mempalace(converted_root: Path, args: argparse.Namespace) -> int:
base_commands: list[list[str]] = []
if shutil_which("uvx"):
base_commands.append(["uvx", "--from", "mempalace", "mempalace"])
if shutil_which("uv"):
base_commands.append(["uv", "run", "--with", "mempalace", "python", "-m", "mempalace"])
if shutil_which("mempalace"):
base_commands.append(["mempalace"])
for base in base_commands:
command = [
*base,
"mine",
str(converted_root),
"--mode",
"convos",
"--wing",
args.wing,
"--agent",
args.agent,
]
if args.extract == "general":
command.extend(["--extract", "general"])
if args.dry_run:
command.append("--dry-run")
completed = subprocess.run(command, check=False)
if completed.returncode == 0:
return 0
return 1
def shutil_which(command: str) -> str | None:
from shutil import which
return which(command)
def main() -> int:
args = parse_args()
try:
source_paths = resolve_inputs(args)
except FileNotFoundError as error:
print(str(error), file=sys.stderr)
return 1
output_root = Path(args.output_dir).expanduser().resolve()
converted_paths = [
converted
for source_path in source_paths
for converted in [write_converted_transcript(source_path, output_root)]
if converted is not None
]
if args.verbose:
for path in converted_paths:
print(path)
if not converted_paths:
print("No convertible VS Code Copilot transcripts found", file=sys.stderr)
return 1
if args.convert_only:
return 0
return run_mempalace(output_root, args)
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
# MEMPALACE PRE-COMPACT HOOK — VS Code/Copilot variant
#
# Runs right before Copilot compacts the conversation, mining the active
# transcript plus an optional project directory into MemPalace.
STATE_DIR="${HOME}/.mempalace/hook_state"
mkdir -p "$STATE_DIR"
INPUT="$(cat)"
PYTHON_BIN="${MEMPAL_PYTHON:-}"
if [ -z "$PYTHON_BIN" ] || [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
fi
run_mempalace() {
if command -v uvx >/dev/null 2>&1; then
uvx --from mempalace mempalace "$@"
return $?
fi
if command -v uv >/dev/null 2>&1; then
uv run --with mempalace python -m mempalace "$@"
return $?
fi
mempalace "$@"
}
run_vscode_transcript_miner() {
./.bin/mempal_mine_vscode_transcripts.py "$@"
}
_mempal_parsed=$(
umask 077
printf '%s' "$INPUT" | "$PYTHON_BIN" -c '
import json
import re
import sys
def safe(value: object) -> str:
return re.sub(r"[^a-zA-Z0-9_/ .~:-]", "", str(value or ""))
data = json.load(sys.stdin)
print("__MEMPAL_PARSE_OK__")
print(safe(data.get("sessionId") or data.get("session_id") or "unknown"))
print(safe(data.get("transcript_path") or ""))
print(safe(data.get("cwd") or ""))
' 2>"$STATE_DIR/last_python_err.log"
)
if [ -s "$STATE_DIR/last_python_err.log" ]; then
chmod 600 "$STATE_DIR/last_python_err.log" 2>/dev/null
else
rm -f "$STATE_DIR/last_python_err.log"
fi
PARSE_MARKER="$(printf '%s\n' "$_mempal_parsed" | sed -n '1p')"
SESSION_ID="$(printf '%s\n' "$_mempal_parsed" | sed -n '2p')"
TRANSCRIPT_PATH="$(printf '%s\n' "$_mempal_parsed" | sed -n '3p')"
HOOK_CWD="$(printf '%s\n' "$_mempal_parsed" | sed -n '4p')"
SESSION_ID="${SESSION_ID:-unknown}"
TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-}"
HOOK_CWD="${HOOK_CWD:-$PWD}"
if [ -n "$INPUT" ] && [ "$PARSE_MARKER" != "__MEMPAL_PARSE_OK__" ]; then
echo "[$(date '+%H:%M:%S')] WARN: input parse failed; see $STATE_DIR/last_input.log and $STATE_DIR/last_python_err.log" >> "$STATE_DIR/hook.log"
( umask 077 && printf '%s' "$INPUT" | head -c 4096 > "$STATE_DIR/last_input.log" )
chmod 600 "$STATE_DIR/last_input.log" 2>/dev/null
fi
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
is_valid_transcript_path() {
local candidate="$1"
[ -n "$candidate" ] || return 1
case "$candidate" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$candidate/" in
*/../*) return 1 ;;
esac
return 0
}
resolve_dir() {
local candidate="$1"
local base_dir="$2"
candidate="${candidate/#\~/$HOME}"
if [ -z "$candidate" ]; then
return 1
fi
case "$candidate" in
/*) printf '%s\n' "$candidate" ;;
*) printf '%s\n' "$base_dir/$candidate" ;;
esac
}
discover_transcript_path() {
local session_id="$1"
local storage_root="${VSCODE_WORKSPACE_STORAGE_DIR:-$HOME/.config/Code/User/workspaceStorage}"
local discovered_path=""
if ! command -v find >/dev/null 2>&1 || [ ! -d "$storage_root" ]; then
return 1
fi
if [ -n "$session_id" ] && [ "$session_id" != "unknown" ]; then
discovered_path="$(find "$storage_root" -path "*/GitHub.copilot-chat/transcripts/${session_id}.jsonl" -o -path "*/GitHub.copilot-chat/transcripts/${session_id}.json" 2>/dev/null | head -n 1)"
fi
if [ -z "$discovered_path" ]; then
discovered_path="$(find "$storage_root" -path '*/GitHub.copilot-chat/transcripts/*' -type f \( -name '*.json' -o -name '*.jsonl' \) -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n 1 | cut -d' ' -f2-)"
fi
[ -n "$discovered_path" ] || return 1
printf '%s\n' "$discovered_path"
}
echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$STATE_DIR/hook.log"
if ! is_valid_transcript_path "$TRANSCRIPT_PATH" || [ ! -f "$TRANSCRIPT_PATH" ]; then
DISCOVERED_TRANSCRIPT_PATH="$(discover_transcript_path "$SESSION_ID" 2>/dev/null || true)"
if is_valid_transcript_path "$DISCOVERED_TRANSCRIPT_PATH" && [ -f "$DISCOVERED_TRANSCRIPT_PATH" ]; then
TRANSCRIPT_PATH="$DISCOVERED_TRANSCRIPT_PATH"
echo "[$(date '+%H:%M:%S')] Auto-discovered transcript path: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
fi
fi
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
run_vscode_transcript_miner --transcript-path "$TRANSCRIPT_PATH" --session-id "$SESSION_ID" >> "$STATE_DIR/hook.log" 2>&1
elif [ -n "$TRANSCRIPT_PATH" ]; then
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
fi
PROJECT_DIR="$(resolve_dir "${MEMPAL_DIR:-}" "$HOOK_CWD" 2>/dev/null || true)"
if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then
run_mempalace mine "$PROJECT_DIR" --mode projects >> "$STATE_DIR/hook.log" 2>&1
fi
echo '{}'
#!/usr/bin/env bash
set -euo pipefail
DEFAULT_TRANSCRIPTS_DIR="$HOME/.config/Code/User/workspaceStorage/<workspace-storage-id>/GitHub.copilot-chat/transcripts"
TRANSCRIPTS_DIR="${MEMPAL_VSCODE_TRANSCRIPTS_DIR:-$DEFAULT_TRANSCRIPTS_DIR}"
OUTPUT_DIR="${MEMPAL_VSCODE_OUTPUT_DIR:-$HOME/.mempalace/vscode-copilot-transcripts}"
if [[ $# -gt 0 ]] && [[ "$1" != --* ]]; then
TRANSCRIPTS_DIR="$1"
shift
fi
if [[ ! -d "$TRANSCRIPTS_DIR" ]]; then
echo "Transcript directory not found: $TRANSCRIPTS_DIR" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
exec "$(dirname "$0")/mempal_mine_vscode_transcripts.py" \
"$TRANSCRIPTS_DIR" \
--output-dir "$OUTPUT_DIR" \
"$@"
#!/usr/bin/env bash
# MEMPALACE SAVE HOOK — VS Code/Copilot variant
#
# Runs on Stop. Every SAVE_INTERVAL user messages, it checkpoints MemPalace and
# can optionally block stop in verbose mode so the model writes a diary entry.
SAVE_INTERVAL="${MEMPAL_SAVE_INTERVAL:-5}"
STATE_DIR="${HOME}/.mempalace/hook_state"
mkdir -p "$STATE_DIR"
INPUT="$(cat)"
PYTHON_BIN="${MEMPAL_PYTHON:-}"
if [ -z "$PYTHON_BIN" ] || [ ! -x "$PYTHON_BIN" ]; then
PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
fi
run_mempalace() {
if command -v uvx >/dev/null 2>&1; then
uvx --from mempalace mempalace "$@"
return $?
fi
if command -v uv >/dev/null 2>&1; then
uv run --with mempalace python -m mempalace "$@"
return $?
fi
mempalace "$@"
}
run_vscode_transcript_miner() {
./.bin/mempal_mine_vscode_transcripts.py "$@"
}
_mempal_parsed=$(
umask 077
printf '%s' "$INPUT" | "$PYTHON_BIN" -c '
import json
import re
import sys
def safe(value: object) -> str:
return re.sub(r"[^a-zA-Z0-9_/ .~:-]", "", str(value or ""))
data = json.load(sys.stdin)
stop_hook_active_raw = data.get("stop_hook_active", False)
stop_hook_active = "True" if stop_hook_active_raw is True or str(stop_hook_active_raw).lower() in ("true", "1", "yes") else "False"
print("__MEMPAL_PARSE_OK__")
print(safe(data.get("sessionId") or data.get("session_id") or "unknown"))
print(stop_hook_active)
print(safe(data.get("transcript_path") or ""))
print(safe(data.get("cwd") or ""))
' 2>"$STATE_DIR/last_python_err.log"
)
if [ -s "$STATE_DIR/last_python_err.log" ]; then
chmod 600 "$STATE_DIR/last_python_err.log" 2>/dev/null
else
rm -f "$STATE_DIR/last_python_err.log"
fi
PARSE_MARKER="$(printf '%s\n' "$_mempal_parsed" | sed -n '1p')"
SESSION_ID="$(printf '%s\n' "$_mempal_parsed" | sed -n '2p')"
STOP_HOOK_ACTIVE="$(printf '%s\n' "$_mempal_parsed" | sed -n '3p')"
TRANSCRIPT_PATH="$(printf '%s\n' "$_mempal_parsed" | sed -n '4p')"
HOOK_CWD="$(printf '%s\n' "$_mempal_parsed" | sed -n '5p')"
SESSION_ID="${SESSION_ID:-unknown}"
STOP_HOOK_ACTIVE="${STOP_HOOK_ACTIVE:-False}"
TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-}"
HOOK_CWD="${HOOK_CWD:-$PWD}"
if [ -n "$INPUT" ] && [ "$PARSE_MARKER" != "__MEMPAL_PARSE_OK__" ]; then
echo "[$(date '+%H:%M:%S')] WARN: input parse failed; see $STATE_DIR/last_input.log and $STATE_DIR/last_python_err.log" >> "$STATE_DIR/hook.log"
( umask 077 && printf '%s' "$INPUT" | head -c 4096 > "$STATE_DIR/last_input.log" )
chmod 600 "$STATE_DIR/last_input.log" 2>/dev/null
fi
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
is_valid_transcript_path() {
local candidate="$1"
[ -n "$candidate" ] || return 1
case "$candidate" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$candidate/" in
*/../*) return 1 ;;
esac
return 0
}
resolve_dir() {
local candidate="$1"
local base_dir="$2"
candidate="${candidate/#\~/$HOME}"
if [ -z "$candidate" ]; then
return 1
fi
case "$candidate" in
/*) printf '%s\n' "$candidate" ;;
*) printf '%s\n' "$base_dir/$candidate" ;;
esac
}
discover_transcript_path() {
local session_id="$1"
local storage_root="${VSCODE_WORKSPACE_STORAGE_DIR:-$HOME/.config/Code/User/workspaceStorage}"
local discovered_path=""
if ! command -v find >/dev/null 2>&1 || [ ! -d "$storage_root" ]; then
return 1
fi
if [ -n "$session_id" ] && [ "$session_id" != "unknown" ]; then
discovered_path="$(find "$storage_root" -path "*/GitHub.copilot-chat/transcripts/${session_id}.jsonl" -o -path "*/GitHub.copilot-chat/transcripts/${session_id}.json" 2>/dev/null | head -n 1)"
fi
if [ -z "$discovered_path" ]; then
discovered_path="$(find "$storage_root" -path '*/GitHub.copilot-chat/transcripts/*' -type f \( -name '*.json' -o -name '*.jsonl' \) -printf '%T@ %p\n' 2>/dev/null | sort -nr | head -n 1 | cut -d' ' -f2-)"
fi
[ -n "$discovered_path" ] || return 1
printf '%s\n' "$discovered_path"
}
if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then
echo '{}'
exit 0
fi
if ! is_valid_transcript_path "$TRANSCRIPT_PATH" || [ ! -f "$TRANSCRIPT_PATH" ]; then
DISCOVERED_TRANSCRIPT_PATH="$(discover_transcript_path "$SESSION_ID" 2>/dev/null || true)"
if is_valid_transcript_path "$DISCOVERED_TRANSCRIPT_PATH" && [ -f "$DISCOVERED_TRANSCRIPT_PATH" ]; then
TRANSCRIPT_PATH="$DISCOVERED_TRANSCRIPT_PATH"
echo "[$(date '+%H:%M:%S')] Auto-discovered transcript path: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
fi
fi
EXCHANGE_COUNT=0
if [ -f "$TRANSCRIPT_PATH" ]; then
EXCHANGE_COUNT=$("$PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
import json
import sys
count = 0
with open(sys.argv[1], encoding="utf-8") as handle:
for line in handle:
try:
entry = json.loads(line)
message = entry.get("message", {})
if isinstance(message, dict) and message.get("role") == "user":
content = message.get("content", "")
if isinstance(content, str) and "<command-message>" in content:
continue
count += 1
except Exception:
pass
print(count)
PYEOF
2>/dev/null)
fi
LAST_SAVE_FILE="$STATE_DIR/${SESSION_ID}_last_save"
LAST_SAVE=0
if [ -f "$LAST_SAVE_FILE" ]; then
LAST_SAVE_RAW="$(cat "$LAST_SAVE_FILE")"
if [[ "$LAST_SAVE_RAW" =~ ^[0-9]+$ ]]; then
LAST_SAVE="$LAST_SAVE_RAW"
fi
fi
SINCE_LAST=$((EXCHANGE_COUNT - LAST_SAVE))
echo "[$(date '+%H:%M:%S')] Session $SESSION_ID: $EXCHANGE_COUNT exchanges, $SINCE_LAST since last save" >> "$STATE_DIR/hook.log"
if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
echo "$EXCHANGE_COUNT" > "$LAST_SAVE_FILE"
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
run_vscode_transcript_miner --transcript-path "$TRANSCRIPT_PATH" --session-id "$SESSION_ID" >> "$STATE_DIR/hook.log" 2>&1 &
elif [ -n "$TRANSCRIPT_PATH" ]; then
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
fi
PROJECT_DIR="$(resolve_dir "${MEMPAL_DIR:-}" "$HOOK_CWD" 2>/dev/null || true)"
if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then
run_mempalace mine "$PROJECT_DIR" --mode projects >> "$STATE_DIR/hook.log" 2>&1 &
fi
if [ "$MEMPAL_VERBOSE" = "true" ] || [ "$MEMPAL_VERBOSE" = "1" ]; then
cat <<'HOOKJSON'
{
"hookSpecificOutput": {
"hookEventName": "Stop",
"decision": "block",
"reason": "MemPalace save checkpoint. Write a brief session diary entry covering key topics, decisions, and code changes since the last save. Use verbatim quotes where possible. Continue after saving."
}
}
HOOKJSON
else
echo '{}'
fi
else
echo '{}'
fi

MemPalace + VS Code Copilot MCP and Hook Setup

This note documents a working MemPalace setup for VS Code + GitHub Copilot, including MCP registration, hook wiring, and a custom transcript conversion flow for Copilot chat transcripts.

All paths below are sanitized. Replace placeholders like ${HOME} and ${WORKSPACE} with your own values.

Why a Custom Transcript Flow Is Needed

MemPalace conversation mining supports several formats out of the box, including ChatGPT exports, Claude JSON, Slack exports, Markdown, and plain text transcripts.

VS Code Copilot chat transcripts are different.

MemPalace's ChatGPT import path expects a turn-oriented JSON structure with a mapping tree. VS Code Copilot stores chat history as JSONL event logs under a workspace storage directory, with records like:

  • session.start
  • user.message
  • assistant.message
  • assistant.turn_start
  • assistant.turn_end
  • tool.execution_start
  • tool.execution_complete

Because of that mismatch, raw VS Code Copilot transcript files should not be passed directly to mempalace mine --mode convos.

The working solution is:

  1. Discover VS Code Copilot transcript files.
  2. Convert the event stream into a plain-text transcript format MemPalace already accepts.
  3. Mine those converted transcripts with mempalace mine ... --mode convos.

VS Code MCP Setup

Add a MemPalace server entry to .vscode/mcp.json:

{
  "servers": {
    "mempalace": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--with", "mempalace", "python", "-m", "mempalace.mcp_server"]
    }
  }
}

This uses uv instead of requiring a permanently activated virtualenv.

If you already have other MCP servers configured, merge the mempalace entry into your existing servers object instead of replacing the whole file.

Example of a fuller .vscode/mcp.json:

{
  "servers": {
    "serena": {
      "type": "stdio",
      "command": "serena",
      "args": ["start-mcp-server", "--context=vscode", "--project", "${workspaceFolder}"]
    },
    "context-mode": {
      "type": "stdio",
      "command": "context-mode"
    },
    "mempalace": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--with", "mempalace", "python", "-m", "mempalace.mcp_server"]
    },
    "git": {
      "type": "stdio",
      "command": "uvx",
      "args": ["mcp-server-git", "--repository", "${workspaceFolder}"]
    },
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "${workspaceFolder}"]
    },
    "eslint": {
      "type": "stdio",
      "command": "npx",
      "args": ["@eslint/mcp@latest"]
    }
  },
  "inputs": []
}

That full example is optional. The only MemPalace-specific part is the mempalace server block.

VS Code Hook Setup

Create .github/hooks/mempalace.json:

{
  "hooks": {
    "Stop": [
      {
        "type": "command",
        "command": "./.bin/mempal_save_hook.sh",
        "cwd": ".",
        "timeout": 120,
        "env": {
          "MEMPAL_DIR": "."
        }
      }
    ],
    "PreCompact": [
      {
        "type": "command",
        "command": "./.bin/mempal_precompact_hook.sh",
        "cwd": ".",
        "timeout": 120,
        "env": {
          "MEMPAL_DIR": "."
        }
      }
    ]
  }
}

These hooks are VS Code/Copilot variants of the upstream MemPalace save/precompact hooks.

Scripts Used

Store the scripts in ./.bin/:

  • mempal_precompact_hook.sh
  • mempal_save_hook.sh
  • mempal_mine_vscode_transcripts.py
  • mempal_reseed_vscode_transcripts.sh

Make them executable:

chmod +x .bin/mempal_precompact_hook.sh
chmod +x .bin/mempal_save_hook.sh
chmod +x .bin/mempal_mine_vscode_transcripts.py
chmod +x .bin/mempal_reseed_vscode_transcripts.sh

Transcript Discovery

VS Code Copilot transcripts live under a workspace storage path like this:

${HOME}/.config/Code/User/workspaceStorage/<workspace-storage-id>/GitHub.copilot-chat/transcripts

The hooks use transcript discovery logic that:

  1. Uses transcript_path when the hook payload provides it.
  2. Otherwise searches the VS Code workspace storage directory.
  3. Prefers a filename matching the Copilot sessionId.
  4. Falls back to the most recently modified transcript file.

An optional override is supported:

export VSCODE_WORKSPACE_STORAGE_DIR="${HOME}/.config/Code/User/workspaceStorage"

Transcript Conversion Strategy

The custom converter script reads the Copilot JSONL event stream and keeps only real conversation turns:

  • user.message -> user turn
  • assistant.message -> assistant turn

It ignores lifecycle and tool execution noise, then emits plain-text transcript content like:

> User question
Assistant answer

> Next question
Next answer

This output is written to a cache directory, by default:

${HOME}/.mempalace/vscode-copilot-transcripts

Each converted file is named by session ID when available.

One-Off Reseed Script

To re-import everything from the VS Code Copilot transcript folder, use:

./.bin/mempal_reseed_vscode_transcripts.sh

This script:

  1. Targets the Copilot transcript directory.
  2. Clears the converted transcript cache.
  3. Rebuilds the converted plain-text transcript set.
  4. Runs MemPalace conversation mining on the converted output.

Optional modes:

./.bin/mempal_reseed_vscode_transcripts.sh --dry-run
./.bin/mempal_reseed_vscode_transcripts.sh --extract general
./.bin/mempal_reseed_vscode_transcripts.sh --convert-only

You can also override the source directory:

./.bin/mempal_reseed_vscode_transcripts.sh /path/to/GitHub.copilot-chat/transcripts

Or set it via environment:

export MEMPAL_VSCODE_TRANSCRIPTS_DIR="/path/to/GitHub.copilot-chat/transcripts"

Fresh-Start Flow

If you want to rebuild MemPalace from scratch:

rm -rf ${HOME}/.mempalace
./.bin/mempal_reseed_vscode_transcripts.sh

If you also want general memory extraction:

rm -rf ${HOME}/.mempalace
./.bin/mempal_reseed_vscode_transcripts.sh
./.bin/mempal_reseed_vscode_transcripts.sh --extract general

Important Caveat

MemPalace's conversation miner currently treats conversation files as effectively immutable for idempotency checks.

That means the custom converter/reseed flow works well for:

  • first-time imports
  • full reseeds after wiping the palace
  • historical backfills

But repeated incremental re-mining of an already-filed converted transcript may still be skipped by MemPalace unless the underlying miner behavior changes.

So the safest model today is:

  • use hooks for ongoing capture attempts
  • use the reseed script when doing a fresh rebuild or historical import

Files Added in This Setup

  • .vscode/mcp.json updated with the MemPalace MCP server
  • .github/hooks/mempalace.json
  • .bin/mempal_precompact_hook.sh
  • .bin/mempal_save_hook.sh
  • .bin/mempal_mine_vscode_transcripts.py
  • .bin/mempal_reseed_vscode_transcripts.sh

Minimal Command Summary

# MCP server runs via VS Code using uv

# reseed from VS Code Copilot transcripts
./.bin/mempal_reseed_vscode_transcripts.sh

# dry-run
./.bin/mempal_reseed_vscode_transcripts.sh --dry-run

# general extraction pass
./.bin/mempal_reseed_vscode_transcripts.sh --extract general
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment