memorykit 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 — 2026-08-30
4
+
5
+ - Add safe automatic project detection for GitHub Copilot plugin installs.
6
+ `SessionStart` accepts only the host-supplied `cwd` and `sessionId`, requires
7
+ `sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, resolves the Git
8
+ top-level and a canonical `github.com/owner/repository` origin through the
9
+ provider's existing normalizer, and fails closed for missing, foreign-host,
10
+ deep-namespace, invalid, or conflicting repository context.
11
+ - Persist only `session_id` and normalized `project` in a private, atomic,
12
+ idempotent binding under `CONTEXT_KIT_MEMORY_HOME/session-bindings`. Session
13
+ IDs are validated as safe filenames, same-session cross-project reuse is
14
+ refused, explicit configuration keeps precedence, and `SessionEnd` removes
15
+ the matching binding. Crash leftovers cannot cross sessions because lookup is
16
+ keyed by the host-generated session ID.
17
+ - Fail closed on non-POSIX hosts, where the standard library cannot verify
18
+ owner-only ACLs for the binding root. Windows and other unsupported platforms
19
+ retain the full explicit project configuration path.
20
+ - Keep project resolution in the provider used by both CLI and MCP. MCP tool
21
+ arguments, prompt content, MCP working directory, and `PWD` remain ineligible
22
+ scope inputs. Hosts without Copilot's matching hook/session-ID support,
23
+ including APM, still require explicit project configuration.
24
+
3
25
  ## 0.7.0 — 2026-08-29
4
26
 
5
27
  - Make plugin-installed MCP safe and configurable in GitHub Copilot: declare
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memorykit
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Provenance-bound durable memory: an immutable record contract, a validator, append-only review state, and a standard-library stdio MCP server. No Python dependencies; requires git on PATH.
5
5
  Project-URL: Homepage, https://github.com/mbeacom/context-kit
6
6
  Project-URL: Repository, https://github.com/mbeacom/context-kit
@@ -126,7 +126,7 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
126
126
 
127
127
  ## Local-only reviewed records
128
128
 
129
- Python 3 and Git on `PATH` are required. Configure an explicit project and
129
+ Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
130
130
  plugin root:
131
131
 
132
132
  ```bash
@@ -237,13 +237,23 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
237
237
  CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
238
238
  ```
239
239
 
240
- The bundled `.mcp.json` forwards `CONTEXT_KIT_MEMORY_PROJECT` from the host
241
- environment into the plugin process. Set it to the current
242
- `owner/repository` in the Copilot project environment or before launching the
243
- host; do not edit Copilot's generated MCP entry. MCP capture records must use
244
- an absolute `source` path because plugin hosts may launch the server outside
245
- the active repository. The server refuses an unset project and never infers a
246
- repository or falls back to a global store.
240
+ The bundled `.mcp.json` forwards explicit project/home configuration into the
241
+ plugin process. GitHub Copilot Desktop can additionally bind the MCP child to
242
+ the active project through trusted host data: `SessionStart` requires payload
243
+ `sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, then resolves payload
244
+ `cwd` to the Git top-level and accepts only a canonical
245
+ `github.com/owner/repository` origin on POSIX. It stores only that session ID and
246
+ project under the private memory home and removes the binding on `SessionEnd`.
247
+
248
+ Explicit `--project`, `CONTEXT_KIT_MEMORY_PROJECT`, deprecated
249
+ `PRODUCTIVITY_SKILLS_MEMORY_PROJECT`, and `CLAUDE_PLUGIN_OPTION_PROJECT` take
250
+ precedence, in that order, over the binding. MCP cwd, `PWD`, prompts, and tool
251
+ arguments never select scope. APM, package-only use, and hosts without the
252
+ matching Copilot hook/session ID still require explicit configuration. The same
253
+ is true on Windows and for other Git hosts or deeper repository namespaces,
254
+ whose privacy or identity cannot be represented safely by this automatic path.
255
+ MCP capture records must use an absolute `source` path because plugin hosts may
256
+ launch the server outside the active repository.
247
257
 
248
258
  The surface can propose memory but **cannot activate it**: a record whose
249
259
  frontmatter is not `review: proposed` is refused, and proposals stay out of
@@ -254,7 +264,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
254
264
 
255
265
  ## Opt-in lifecycle queue
256
266
 
257
- Claude hooks are inert until enabled:
267
+ Recall and payload-queue behavior is inert until enabled:
258
268
 
259
269
  ```bash
260
270
  export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
@@ -266,6 +276,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
266
276
  both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
267
277
  explicit command there.
268
278
 
279
+ Copilot's routing-only `SessionStart` binding is the bounded exception: it is
280
+ created even when both switches are off, contains no transcript or secret, and
281
+ is cleaned at `SessionEnd` (not `Stop`/`agentStop`). It is ephemeral routing
282
+ metadata, not automatic memory capture.
283
+
269
284
  ## Components
270
285
 
271
286
  | Component | Purpose |
@@ -83,7 +83,7 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
83
83
 
84
84
  ## Local-only reviewed records
85
85
 
86
- Python 3 and Git on `PATH` are required. Configure an explicit project and
86
+ Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
87
87
  plugin root:
88
88
 
89
89
  ```bash
@@ -194,13 +194,23 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
194
194
  CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
195
195
  ```
196
196
 
197
- The bundled `.mcp.json` forwards `CONTEXT_KIT_MEMORY_PROJECT` from the host
198
- environment into the plugin process. Set it to the current
199
- `owner/repository` in the Copilot project environment or before launching the
200
- host; do not edit Copilot's generated MCP entry. MCP capture records must use
201
- an absolute `source` path because plugin hosts may launch the server outside
202
- the active repository. The server refuses an unset project and never infers a
203
- repository or falls back to a global store.
197
+ The bundled `.mcp.json` forwards explicit project/home configuration into the
198
+ plugin process. GitHub Copilot Desktop can additionally bind the MCP child to
199
+ the active project through trusted host data: `SessionStart` requires payload
200
+ `sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, then resolves payload
201
+ `cwd` to the Git top-level and accepts only a canonical
202
+ `github.com/owner/repository` origin on POSIX. It stores only that session ID and
203
+ project under the private memory home and removes the binding on `SessionEnd`.
204
+
205
+ Explicit `--project`, `CONTEXT_KIT_MEMORY_PROJECT`, deprecated
206
+ `PRODUCTIVITY_SKILLS_MEMORY_PROJECT`, and `CLAUDE_PLUGIN_OPTION_PROJECT` take
207
+ precedence, in that order, over the binding. MCP cwd, `PWD`, prompts, and tool
208
+ arguments never select scope. APM, package-only use, and hosts without the
209
+ matching Copilot hook/session ID still require explicit configuration. The same
210
+ is true on Windows and for other Git hosts or deeper repository namespaces,
211
+ whose privacy or identity cannot be represented safely by this automatic path.
212
+ MCP capture records must use an absolute `source` path because plugin hosts may
213
+ launch the server outside the active repository.
204
214
 
205
215
  The surface can propose memory but **cannot activate it**: a record whose
206
216
  frontmatter is not `review: proposed` is refused, and proposals stay out of
@@ -211,7 +221,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
211
221
 
212
222
  ## Opt-in lifecycle queue
213
223
 
214
- Claude hooks are inert until enabled:
224
+ Recall and payload-queue behavior is inert until enabled:
215
225
 
216
226
  ```bash
217
227
  export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
@@ -223,6 +233,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
223
233
  both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
224
234
  explicit command there.
225
235
 
236
+ Copilot's routing-only `SessionStart` binding is the bounded exception: it is
237
+ created even when both switches are off, contains no transcript or secret, and
238
+ is cleaned at `SessionEnd` (not `Stop`/`agentStop`). It is ephemeral routing
239
+ metadata, not automatic memory capture.
240
+
226
241
  ## Components
227
242
 
228
243
  | Component | Purpose |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memorykit"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Provenance-bound durable memory: an immutable record contract, a validator, append-only review state, and a standard-library stdio MCP server. No Python dependencies; requires git on PATH."
5
5
  requires-python = ">=3.10"
6
6
  keywords = [
@@ -19,6 +19,6 @@ Two entry points, one implementation:
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- __version__ = "0.7.0"
22
+ __version__ = "0.8.0"
23
23
 
24
24
  __all__ = ["__version__"]
@@ -26,7 +26,7 @@ from pathlib import Path
26
26
  from typing import Any
27
27
 
28
28
  SERVER_NAME = "context-kit-memory"
29
- SERVER_VERSION = "0.7.0"
29
+ SERVER_VERSION = "0.8.0"
30
30
  # Newest first. The client's requested version is echoed when supported.
31
31
  SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
32
32
  # The provider is this package's sibling module, so one path is correct in both
@@ -17,6 +17,7 @@ import json
17
17
  import os
18
18
  import re
19
19
  import shutil
20
+ import stat
20
21
  import subprocess
21
22
  import sys
22
23
  import tempfile
@@ -25,6 +26,7 @@ import uuid
25
26
  from dataclasses import dataclass
26
27
  from datetime import datetime, timezone
27
28
  from pathlib import Path
29
+ from urllib.parse import urlsplit
28
30
 
29
31
  SCHEMA = "context-kit/memory-v1"
30
32
  MAX_BYTES = 32 * 1024
@@ -54,9 +56,13 @@ WAKE_SCHEMA = "context-kit/memory-wake-v1"
54
56
  # Session mining recognizes GitHub Copilot CLI event logs
55
57
  # (`~/.copilot/session-state/<session-id>/events.jsonl`).
56
58
  SESSION_PRODUCER = "github-copilot-cli"
57
- # `session_id` arrives from the session log and is used to name a candidate
58
- # file, so it is validated as a single safe path component before use.
59
+ # Session identifiers arrive from Copilot logs and lifecycle payloads and are
60
+ # used in filenames, so they must remain one bounded path component.
59
61
  SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
62
+ COPILOT_SESSION_ID_ENV = "COPILOT_AGENT_SESSION_ID"
63
+ SESSION_BINDING_DIRNAME = "session-bindings"
64
+ SESSION_BINDING_MAX_BYTES = 512
65
+ SESSION_BINDING_KEYS = frozenset({"session_id", "project"})
60
66
  MAX_TURN_CHARS = 2000
61
67
  MAX_CANDIDATE_TURNS = 400
62
68
  # A wake digest primes a session, so it competes with real work for context.
@@ -437,7 +443,7 @@ def _indexkit_home() -> Path:
437
443
  return default_root / "indexkit"
438
444
 
439
445
 
440
- def _config(args: argparse.Namespace) -> Config:
446
+ def _config(args: argparse.Namespace, *, allow_session_binding: bool = True) -> Config:
441
447
  provider = (
442
448
  getattr(args, "provider", None)
443
449
  or _first_env(
@@ -465,6 +471,8 @@ def _config(args: argparse.Namespace) -> Config:
465
471
  "PRODUCTIVITY_SKILLS_MEMORY_PROJECT",
466
472
  "CLAUDE_PLUGIN_OPTION_PROJECT",
467
473
  )
474
+ if not project and allow_session_binding:
475
+ project = _copilot_session_binding_project(home)
468
476
  auto_value = _first_env(
469
477
  "CONTEXT_KIT_MEMORY_AUTO_CAPTURE",
470
478
  "PRODUCTIVITY_SKILLS_MEMORY_AUTO_CAPTURE",
@@ -772,6 +780,288 @@ def _normalize_repository(remote: str) -> str:
772
780
  return "/".join(parts[-2:])
773
781
 
774
782
 
783
+ def _normalize_copilot_origin(remote: str) -> str:
784
+ """Return an unambiguous project identity for automatic Copilot binding.
785
+
786
+ The memory contract represents repositories as `owner/name`, so automatic
787
+ detection cannot safely collapse a different host or a deeper namespace
788
+ onto that shape. Those repositories remain available through explicit
789
+ project configuration.
790
+ """
791
+ value = remote.strip()
792
+ host = ""
793
+ path = ""
794
+ if value.startswith("git@") and ":" in value:
795
+ authority, path = value.split(":", 1)
796
+ host = authority.rsplit("@", 1)[-1]
797
+ elif "://" in value:
798
+ parsed = urlsplit(value)
799
+ host = parsed.hostname or ""
800
+ path = parsed.path
801
+ if parsed.query or parsed.fragment:
802
+ raise Refusal(
803
+ "automatic Copilot project detection refuses origin queries "
804
+ "and fragments"
805
+ )
806
+ else:
807
+ raise Refusal(
808
+ "automatic Copilot project detection requires a canonical GitHub origin"
809
+ )
810
+ if host.lower() != "github.com":
811
+ raise Refusal(
812
+ "automatic Copilot project detection requires a github.com origin"
813
+ )
814
+ parts = [part for part in path.strip("/").split("/") if part]
815
+ if parts and parts[-1].endswith(".git"):
816
+ parts[-1] = parts[-1][:-4]
817
+ if len(parts) != 2 or not all(parts):
818
+ raise Refusal(
819
+ "automatic Copilot project detection requires exactly owner/repository"
820
+ )
821
+ project = _normalize_repository(value)
822
+ if project != "/".join(parts) or not REPOSITORY_RE.fullmatch(project):
823
+ raise Refusal("repository origin is not a concrete owner/name identity")
824
+ return project
825
+
826
+
827
+ def _validate_session_id(value: object) -> str:
828
+ if not isinstance(value, str) or not SESSION_ID_RE.fullmatch(value):
829
+ raise Refusal("Copilot session id is not a safe path component")
830
+ return value
831
+
832
+
833
+ def _copilot_session_id() -> str | None:
834
+ value = os.environ.get(COPILOT_SESSION_ID_ENV)
835
+ if not value:
836
+ return None
837
+ return _validate_session_id(value)
838
+
839
+
840
+ def _private_session_bindings_supported() -> bool:
841
+ return os.name == "posix"
842
+
843
+
844
+ def _session_binding_directory(home: Path, *, create: bool) -> Path | None:
845
+ if not _private_session_bindings_supported():
846
+ raise Refusal(
847
+ "automatic Copilot project detection requires verifiable POSIX "
848
+ "owner-only permissions; set CONTEXT_KIT_MEMORY_PROJECT explicitly"
849
+ )
850
+ directory = home / SESSION_BINDING_DIRNAME
851
+ if create:
852
+ home.mkdir(parents=True, exist_ok=True)
853
+ try:
854
+ os.mkdir(directory, 0o700)
855
+ except FileExistsError:
856
+ pass
857
+ try:
858
+ metadata = directory.lstat()
859
+ except FileNotFoundError:
860
+ return None
861
+ if not stat.S_ISDIR(metadata.st_mode):
862
+ raise Refusal(f"session binding root is not a real directory: {directory}")
863
+ if os.name == "posix":
864
+ if metadata.st_uid != os.getuid():
865
+ raise Refusal(f"session binding root is owned by another user: {directory}")
866
+ if stat.S_IMODE(metadata.st_mode) & 0o077:
867
+ raise Refusal(f"session binding root permissions must be 0700: {directory}")
868
+ return directory
869
+
870
+
871
+ def _session_binding_path(
872
+ home: Path, session_id: str, *, create_directory: bool
873
+ ) -> Path | None:
874
+ safe_id = _validate_session_id(session_id)
875
+ directory = _session_binding_directory(home, create=create_directory)
876
+ if directory is None:
877
+ return None
878
+ return directory / f"{safe_id}.json"
879
+
880
+
881
+ def _load_session_binding(home: Path, session_id: str) -> dict[str, object] | None:
882
+ path = _session_binding_path(home, session_id, create_directory=False)
883
+ if path is None:
884
+ return None
885
+ flags = os.O_RDONLY
886
+ if hasattr(os, "O_NOFOLLOW"):
887
+ flags |= os.O_NOFOLLOW
888
+ try:
889
+ descriptor = os.open(path, flags)
890
+ except FileNotFoundError:
891
+ return None
892
+ except OSError as exc:
893
+ raise Refusal(f"cannot safely open Copilot session binding: {path}") from exc
894
+ try:
895
+ metadata = os.fstat(descriptor)
896
+ if not stat.S_ISREG(metadata.st_mode):
897
+ raise Refusal(f"Copilot session binding is not a regular file: {path}")
898
+ if os.name == "posix":
899
+ if metadata.st_uid != os.getuid():
900
+ raise Refusal(
901
+ f"Copilot session binding is owned by another user: {path}"
902
+ )
903
+ if stat.S_IMODE(metadata.st_mode) & 0o077:
904
+ raise Refusal(
905
+ f"Copilot session binding permissions must be 0600: {path}"
906
+ )
907
+ if metadata.st_size > SESSION_BINDING_MAX_BYTES:
908
+ raise Refusal(f"Copilot session binding is unexpectedly large: {path}")
909
+ with os.fdopen(descriptor, "rb") as stream:
910
+ descriptor = -1
911
+ raw = stream.read(SESSION_BINDING_MAX_BYTES + 1)
912
+ finally:
913
+ if descriptor >= 0:
914
+ os.close(descriptor)
915
+ if len(raw) > SESSION_BINDING_MAX_BYTES:
916
+ raise Refusal(f"Copilot session binding is unexpectedly large: {path}")
917
+ try:
918
+ payload = json.loads(raw)
919
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
920
+ raise Refusal(f"Copilot session binding is invalid JSON: {path}") from exc
921
+ if not isinstance(payload, dict) or set(payload) != SESSION_BINDING_KEYS:
922
+ raise Refusal(f"Copilot session binding has unexpected fields: {path}")
923
+ if payload.get("session_id") != session_id:
924
+ raise Refusal(f"Copilot session binding id does not match its filename: {path}")
925
+ return payload
926
+
927
+
928
+ def _read_session_binding(home: Path, session_id: str) -> str | None:
929
+ payload = _load_session_binding(home, session_id)
930
+ if payload is None:
931
+ return None
932
+ path = _session_binding_path(home, session_id, create_directory=False)
933
+ assert path is not None
934
+ project = payload.get("project")
935
+ if not isinstance(project, str) or not REPOSITORY_RE.fullmatch(project):
936
+ raise Refusal(f"Copilot session binding has an invalid project: {path}")
937
+ return project
938
+
939
+
940
+ def _session_binding_bytes(session_id: str, project: str) -> bytes:
941
+ raw = (
942
+ json.dumps(
943
+ {"session_id": session_id, "project": project},
944
+ sort_keys=True,
945
+ separators=(",", ":"),
946
+ )
947
+ + "\n"
948
+ ).encode("utf-8")
949
+ if len(raw) > SESSION_BINDING_MAX_BYTES:
950
+ raise Refusal("Copilot session binding exceeds its size limit")
951
+ return raw
952
+
953
+
954
+ def _replace_session_binding(path: Path, *, session_id: str, project: str) -> None:
955
+ raw = _session_binding_bytes(session_id, project)
956
+ with tempfile.NamedTemporaryFile(
957
+ dir=path.parent,
958
+ prefix=f".{path.name}.",
959
+ delete=False,
960
+ ) as handle:
961
+ handle.write(raw)
962
+ handle.flush()
963
+ os.fsync(handle.fileno())
964
+ temporary = Path(handle.name)
965
+ os.chmod(temporary, 0o600)
966
+ try:
967
+ os.replace(temporary, path)
968
+ finally:
969
+ temporary.unlink(missing_ok=True)
970
+
971
+
972
+ def _write_session_binding(home: Path, session_id: str, project: str) -> str:
973
+ if not REPOSITORY_RE.fullmatch(project):
974
+ raise Refusal("memory project must be a concrete owner/name identity")
975
+ path = _session_binding_path(home, session_id, create_directory=True)
976
+ assert path is not None
977
+ raw = _session_binding_bytes(session_id, project)
978
+ with tempfile.NamedTemporaryFile(
979
+ dir=path.parent,
980
+ prefix=f".{path.name}.",
981
+ delete=False,
982
+ ) as handle:
983
+ handle.write(raw)
984
+ handle.flush()
985
+ os.fsync(handle.fileno())
986
+ temporary = Path(handle.name)
987
+ os.chmod(temporary, 0o600)
988
+ try:
989
+ try:
990
+ os.link(temporary, path, follow_symlinks=False)
991
+ except FileExistsError:
992
+ existing_payload = _load_session_binding(home, session_id)
993
+ assert existing_payload is not None
994
+ existing = existing_payload.get("project")
995
+ if existing == project:
996
+ return "unchanged"
997
+ if not isinstance(existing, str) or not REPOSITORY_RE.fullmatch(existing):
998
+ raise Refusal("Copilot session binding is already conflicted")
999
+ # A conflicting SessionStart must not leave the old project
1000
+ # addressable through the same session id. Keep only the two-field
1001
+ # minimum payload, but make the project deliberately invalid so all
1002
+ # later resolution fails closed until matching SessionEnd cleanup.
1003
+ _replace_session_binding(path, session_id=session_id, project="")
1004
+ raise Refusal(
1005
+ "refusing to reuse a Copilot session binding for another project"
1006
+ ) from None
1007
+ finally:
1008
+ temporary.unlink(missing_ok=True)
1009
+ return "created"
1010
+
1011
+
1012
+ def _remove_session_binding(home: Path, session_id: str) -> bool:
1013
+ path = _session_binding_path(home, session_id, create_directory=False)
1014
+ if path is None:
1015
+ return False
1016
+ if _load_session_binding(home, session_id) is None:
1017
+ return False
1018
+ path.unlink()
1019
+ return True
1020
+
1021
+
1022
+ def _copilot_session_binding_project(home: Path) -> str | None:
1023
+ session_id = _copilot_session_id()
1024
+ if session_id is None:
1025
+ return None
1026
+ return _read_session_binding(home, session_id)
1027
+
1028
+
1029
+ def _decode_hook_payload(payload: bytes) -> dict[str, object]:
1030
+ try:
1031
+ decoded = json.loads(payload)
1032
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
1033
+ raise Refusal(f"hook payload must be valid JSON: {exc}") from exc
1034
+ if not isinstance(decoded, dict):
1035
+ raise Refusal("hook payload must be a JSON object")
1036
+ return decoded
1037
+
1038
+
1039
+ def _matching_copilot_payload_session(
1040
+ payload: dict[str, object],
1041
+ ) -> str | None:
1042
+ session_id = _copilot_session_id()
1043
+ if session_id is None:
1044
+ return None
1045
+ payload_session_id = _validate_session_id(payload.get("sessionId"))
1046
+ if payload_session_id != session_id:
1047
+ raise Refusal("Copilot environment and hook payload session ids do not match")
1048
+ return session_id
1049
+
1050
+
1051
+ def _project_from_session_start(payload: dict[str, object]) -> str:
1052
+ cwd = payload.get("cwd")
1053
+ if not isinstance(cwd, str) or not cwd or not Path(cwd).is_absolute():
1054
+ raise Refusal("Copilot SessionStart cwd must be an absolute path")
1055
+ repository = Path(cwd).resolve(strict=True)
1056
+ if not repository.is_dir():
1057
+ raise Refusal("Copilot SessionStart cwd must be a directory")
1058
+ top_level = Path(_git(repository, "rev-parse", "--show-toplevel"))
1059
+ if not top_level.is_absolute():
1060
+ raise Refusal("git returned a non-absolute repository top-level")
1061
+ root = top_level.resolve(strict=True)
1062
+ return _normalize_copilot_origin(_git(root, "remote", "get-url", "origin"))
1063
+
1064
+
775
1065
  def _assert_project_matches(metadata: dict[str, object], config: Config) -> None:
776
1066
  project = config.project
777
1067
  if not project:
@@ -2739,32 +3029,86 @@ def _hook_recall(config: Config) -> int:
2739
3029
  return 0
2740
3030
 
2741
3031
 
3032
+ def _hook_session_start(config: Config, payload: bytes) -> int:
3033
+ effective = config
3034
+ try:
3035
+ decoded = _decode_hook_payload(payload)
3036
+ session_id = _matching_copilot_payload_session(decoded)
3037
+ if session_id is not None:
3038
+ project = _project_from_session_start(decoded)
3039
+ _write_session_binding(config.home, session_id, project)
3040
+ if not config.project:
3041
+ effective = Config(
3042
+ provider=config.provider,
3043
+ home=config.home,
3044
+ project=project,
3045
+ auto_capture=config.auto_capture,
3046
+ recall_on_start=config.recall_on_start,
3047
+ )
3048
+ except (OSError, Refusal) as exc:
3049
+ # Routing metadata must fail closed without breaking session startup.
3050
+ print(f"memory session binding skipped: {exc}", file=sys.stderr)
3051
+ return _hook_recall(effective)
3052
+
3053
+
2742
3054
  def _run_hook(event: str, config: Config, payload: bytes) -> int:
2743
3055
  if event == "session-start":
2744
- return _hook_recall(config)
2745
- if not config.auto_capture:
2746
- print("{}")
2747
- return 0
3056
+ return _hook_session_start(config, payload)
3057
+
3058
+ decoded: dict[str, object] | None = None
3059
+ cleanup_session_id: str | None = None
3060
+ effective = config
3061
+ if event == "session-end" and os.environ.get(COPILOT_SESSION_ID_ENV):
3062
+ try:
3063
+ decoded = _decode_hook_payload(payload)
3064
+ cleanup_session_id = _matching_copilot_payload_session(decoded)
3065
+ except (OSError, Refusal) as exc:
3066
+ print(f"memory session binding cleanup skipped: {exc}", file=sys.stderr)
3067
+ cleanup_session_id = None
3068
+ if cleanup_session_id is not None and not config.project:
3069
+ try:
3070
+ bound_project = _read_session_binding(config.home, cleanup_session_id)
3071
+ if bound_project:
3072
+ effective = Config(
3073
+ provider=config.provider,
3074
+ home=config.home,
3075
+ project=bound_project,
3076
+ auto_capture=config.auto_capture,
3077
+ recall_on_start=config.recall_on_start,
3078
+ )
3079
+ except (OSError, Refusal):
3080
+ # Cleanup below still removes a private conflicted binding, but
3081
+ # no memory operation may resolve through it.
3082
+ pass
3083
+
3084
+ response: dict[str, object] = {}
2748
3085
  try:
2749
- decoded = json.loads(payload)
2750
- except (UnicodeDecodeError, json.JSONDecodeError) as exc:
2751
- raise Refusal(f"hook payload must be valid JSON: {exc}") from exc
2752
- if not isinstance(decoded, dict):
2753
- raise Refusal("hook payload must be a JSON object")
2754
- pending_dir = config.home / "pending-hooks" / config.project_slug
2755
- pending = _new_write_once_path(pending_dir, f"-{event}.json")
2756
- if _write_once(pending, payload) != "created":
2757
- raise Refusal(f"refusing to reuse a generated hook payload path: {pending}")
2758
- print(
2759
- json.dumps(
2760
- {
3086
+ if effective.auto_capture:
3087
+ if decoded is None:
3088
+ decoded = _decode_hook_payload(payload)
3089
+ pending_dir = effective.home / "pending-hooks" / effective.project_slug
3090
+ pending = _new_write_once_path(pending_dir, f"-{event}.json")
3091
+ if _write_once(pending, payload) != "created":
3092
+ raise Refusal(
3093
+ f"refusing to reuse a generated hook payload path: {pending}"
3094
+ )
3095
+ response = {
2761
3096
  "status": "queued-for-review",
2762
3097
  "event": event,
2763
3098
  "pending": str(pending),
2764
3099
  "provider_invoked": False,
2765
3100
  }
2766
- )
2767
- )
3101
+ finally:
3102
+ if cleanup_session_id is not None:
3103
+ try:
3104
+ _remove_session_binding(effective.home, cleanup_session_id)
3105
+ except (OSError, Refusal) as exc:
3106
+ # An unsafe or changed binding is left untouched for inspection.
3107
+ print(
3108
+ f"memory session binding cleanup skipped: {exc}",
3109
+ file=sys.stderr,
3110
+ )
3111
+ print(json.dumps(response))
2768
3112
  return 0
2769
3113
 
2770
3114
 
@@ -2902,7 +3246,10 @@ def main(argv: list[str] | None = None) -> int:
2902
3246
  print(json.dumps({"status": "valid", "id": metadata["id"]}))
2903
3247
  return 0
2904
3248
 
2905
- config = _config(args)
3249
+ allow_session_binding = not (
3250
+ args.command == "hook" and args.event in {"session-start", "session-end"}
3251
+ )
3252
+ config = _config(args, allow_session_binding=allow_session_binding)
2906
3253
  if args.command == "capture":
2907
3254
  return _capture_memory(args, config)
2908
3255
  if args.command == "archive-handoff":
File without changes
File without changes