memorykit 0.6.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.
- {memorykit-0.6.0 → memorykit-0.8.0}/CHANGELOG.md +40 -0
- {memorykit-0.6.0 → memorykit-0.8.0}/PKG-INFO +39 -18
- {memorykit-0.6.0 → memorykit-0.8.0}/README.md +37 -16
- {memorykit-0.6.0 → memorykit-0.8.0}/pyproject.toml +1 -1
- {memorykit-0.6.0 → memorykit-0.8.0}/src/memorykit/__init__.py +1 -1
- {memorykit-0.6.0 → memorykit-0.8.0}/src/memorykit/mcp.py +49 -63
- {memorykit-0.6.0 → memorykit-0.8.0}/src/memorykit/provider.py +405 -26
- {memorykit-0.6.0 → memorykit-0.8.0}/.gitignore +0 -0
- {memorykit-0.6.0 → memorykit-0.8.0}/LICENSE +0 -0
- {memorykit-0.6.0 → memorykit-0.8.0}/src/memorykit/__main__.py +0 -0
|
@@ -1,5 +1,45 @@
|
|
|
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
|
+
|
|
25
|
+
## 0.7.0 — 2026-08-29
|
|
26
|
+
|
|
27
|
+
- Make plugin-installed MCP safe and configurable in GitHub Copilot: declare
|
|
28
|
+
`stdio` explicitly and forward the operator-set
|
|
29
|
+
`CONTEXT_KIT_MEMORY_PROJECT` (or deprecated
|
|
30
|
+
`PRODUCTIVITY_SKILLS_MEMORY_PROJECT`) into the child process. The server still
|
|
31
|
+
refuses when no explicit scope is configured; prompt content cannot select
|
|
32
|
+
another project store.
|
|
33
|
+
- Move MCP proposal-only and absolute-source enforcement into the provider, so
|
|
34
|
+
quoted frontmatter follows the same parser as CLI capture and policy cannot
|
|
35
|
+
drift between surfaces. Provider validation now refuses a missing or
|
|
36
|
+
non-regular evidence source instead of skipping hash verification.
|
|
37
|
+
- Bound incoming JSON-RPC frames before decoding and reconcile the MCP server
|
|
38
|
+
version in packaging tests.
|
|
39
|
+
- Add MCP tool safety annotations and collapse missing-source versus
|
|
40
|
+
hash-mismatch failures into one capture refusal, avoiding a path-existence
|
|
41
|
+
distinction while preserving explicit failure.
|
|
42
|
+
|
|
3
43
|
## 0.6.0 — 2026-08-09
|
|
4
44
|
|
|
5
45
|
- **Package the memory contract, validator, and MCP server as `memorykit`, ready
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: memorykit
|
|
3
|
-
Version: 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
|
|
@@ -77,20 +77,16 @@ Installing `memory` also installs `context-handoff`, `verify`, and
|
|
|
77
77
|
The contract, validator, and MCP server are also packaged as **`memorykit`**, a
|
|
78
78
|
pure-standard-library Python package (ADR-0002, ADR-0009).
|
|
79
79
|
|
|
80
|
-
> **Not on PyPI yet.** The package is publish-ready but the name has not been
|
|
81
|
-
> claimed, so `pip install memorykit` does **not** currently install this — it
|
|
82
|
-
> will fail, or, if someone else claims the name first, install something else
|
|
83
|
-
> entirely. Until the release workflow runs, install from a clone.
|
|
84
|
-
|
|
85
80
|
```bash
|
|
86
|
-
#
|
|
87
|
-
pip install ./plugins/memory # or: uv tool install ./plugins/memory
|
|
81
|
+
pip install memorykit # or: uv tool install memorykit
|
|
88
82
|
```
|
|
89
83
|
|
|
90
|
-
|
|
84
|
+
To work from a clone instead — for contributing, or to run an unreleased
|
|
85
|
+
revision:
|
|
91
86
|
|
|
92
87
|
```bash
|
|
93
|
-
|
|
88
|
+
# From a clone of https://github.com/mbeacom/context-kit
|
|
89
|
+
pip install ./plugins/memory # or: uv tool install ./plugins/memory
|
|
94
90
|
```
|
|
95
91
|
|
|
96
92
|
Either way that is the whole install: no bootstrap step, no plugin runtime, no
|
|
@@ -130,7 +126,8 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
|
|
|
130
126
|
|
|
131
127
|
## Local-only reviewed records
|
|
132
128
|
|
|
133
|
-
Python 3
|
|
129
|
+
Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
|
|
130
|
+
plugin root:
|
|
134
131
|
|
|
135
132
|
```bash
|
|
136
133
|
export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
|
|
@@ -223,12 +220,13 @@ authoring a `memory-v1` record from a candidate stays an explicit judgment step.
|
|
|
223
220
|
|
|
224
221
|
See [`references/session-mining.md`](skills/memory-workflows/references/session-mining.md).
|
|
225
222
|
|
|
226
|
-
## MCP surface
|
|
223
|
+
## MCP surface
|
|
227
224
|
|
|
228
225
|
An optional stdio MCP server exposes `memory_recall`, `memory_capture`, and
|
|
229
|
-
`memory_review` so hosts that consume skills plus MCP can use durable memory
|
|
230
|
-
|
|
231
|
-
|
|
226
|
+
`memory_review` so hosts that consume skills plus MCP can use durable memory.
|
|
227
|
+
GitHub Copilot and Claude Code plugin installs discover it automatically;
|
|
228
|
+
package-only hosts can launch it directly. It is standard library only and
|
|
229
|
+
shells out to the same provider, so the CLI and MCP paths cannot drift.
|
|
232
230
|
|
|
233
231
|
```bash
|
|
234
232
|
# From the plugin:
|
|
@@ -239,6 +237,24 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
|
|
|
239
237
|
CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
|
|
240
238
|
```
|
|
241
239
|
|
|
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.
|
|
257
|
+
|
|
242
258
|
The surface can propose memory but **cannot activate it**: a record whose
|
|
243
259
|
frontmatter is not `review: proposed` is refused, and proposals stay out of
|
|
244
260
|
active recall until promoted with the append-only `record-state` CLI.
|
|
@@ -248,7 +264,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
|
|
|
248
264
|
|
|
249
265
|
## Opt-in lifecycle queue
|
|
250
266
|
|
|
251
|
-
|
|
267
|
+
Recall and payload-queue behavior is inert until enabled:
|
|
252
268
|
|
|
253
269
|
```bash
|
|
254
270
|
export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
|
|
@@ -260,6 +276,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
|
|
|
260
276
|
both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
|
|
261
277
|
explicit command there.
|
|
262
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
|
+
|
|
263
284
|
## Components
|
|
264
285
|
|
|
265
286
|
| Component | Purpose |
|
|
@@ -270,7 +291,7 @@ explicit command there.
|
|
|
270
291
|
| `/review-memory` | Review freshness, conflicts, and consolidation proposals. |
|
|
271
292
|
| `/archive-handoff` | Explicitly preserve a validated handoff as historical memory. |
|
|
272
293
|
| `memory-provider.py` | Launcher for the `memorykit` provider: stdlib validator, local store, MemPalace adapter, and hook dispatcher. |
|
|
273
|
-
| `src/memorykit/` | The packaged
|
|
294
|
+
| `src/memorykit/` | The packaged `memorykit` engine: contract, validator, provider, MCP server. |
|
|
274
295
|
|
|
275
296
|
## Safety boundaries
|
|
276
297
|
|
|
@@ -34,20 +34,16 @@ Installing `memory` also installs `context-handoff`, `verify`, and
|
|
|
34
34
|
The contract, validator, and MCP server are also packaged as **`memorykit`**, a
|
|
35
35
|
pure-standard-library Python package (ADR-0002, ADR-0009).
|
|
36
36
|
|
|
37
|
-
> **Not on PyPI yet.** The package is publish-ready but the name has not been
|
|
38
|
-
> claimed, so `pip install memorykit` does **not** currently install this — it
|
|
39
|
-
> will fail, or, if someone else claims the name first, install something else
|
|
40
|
-
> entirely. Until the release workflow runs, install from a clone.
|
|
41
|
-
|
|
42
37
|
```bash
|
|
43
|
-
#
|
|
44
|
-
pip install ./plugins/memory # or: uv tool install ./plugins/memory
|
|
38
|
+
pip install memorykit # or: uv tool install memorykit
|
|
45
39
|
```
|
|
46
40
|
|
|
47
|
-
|
|
41
|
+
To work from a clone instead — for contributing, or to run an unreleased
|
|
42
|
+
revision:
|
|
48
43
|
|
|
49
44
|
```bash
|
|
50
|
-
|
|
45
|
+
# From a clone of https://github.com/mbeacom/context-kit
|
|
46
|
+
pip install ./plugins/memory # or: uv tool install ./plugins/memory
|
|
51
47
|
```
|
|
52
48
|
|
|
53
49
|
Either way that is the whole install: no bootstrap step, no plugin runtime, no
|
|
@@ -87,7 +83,8 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
|
|
|
87
83
|
|
|
88
84
|
## Local-only reviewed records
|
|
89
85
|
|
|
90
|
-
Python 3
|
|
86
|
+
Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
|
|
87
|
+
plugin root:
|
|
91
88
|
|
|
92
89
|
```bash
|
|
93
90
|
export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
|
|
@@ -180,12 +177,13 @@ authoring a `memory-v1` record from a candidate stays an explicit judgment step.
|
|
|
180
177
|
|
|
181
178
|
See [`references/session-mining.md`](skills/memory-workflows/references/session-mining.md).
|
|
182
179
|
|
|
183
|
-
## MCP surface
|
|
180
|
+
## MCP surface
|
|
184
181
|
|
|
185
182
|
An optional stdio MCP server exposes `memory_recall`, `memory_capture`, and
|
|
186
|
-
`memory_review` so hosts that consume skills plus MCP can use durable memory
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
`memory_review` so hosts that consume skills plus MCP can use durable memory.
|
|
184
|
+
GitHub Copilot and Claude Code plugin installs discover it automatically;
|
|
185
|
+
package-only hosts can launch it directly. It is standard library only and
|
|
186
|
+
shells out to the same provider, so the CLI and MCP paths cannot drift.
|
|
189
187
|
|
|
190
188
|
```bash
|
|
191
189
|
# From the plugin:
|
|
@@ -196,6 +194,24 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
|
|
|
196
194
|
CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
|
|
197
195
|
```
|
|
198
196
|
|
|
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.
|
|
214
|
+
|
|
199
215
|
The surface can propose memory but **cannot activate it**: a record whose
|
|
200
216
|
frontmatter is not `review: proposed` is refused, and proposals stay out of
|
|
201
217
|
active recall until promoted with the append-only `record-state` CLI.
|
|
@@ -205,7 +221,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
|
|
|
205
221
|
|
|
206
222
|
## Opt-in lifecycle queue
|
|
207
223
|
|
|
208
|
-
|
|
224
|
+
Recall and payload-queue behavior is inert until enabled:
|
|
209
225
|
|
|
210
226
|
```bash
|
|
211
227
|
export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
|
|
@@ -217,6 +233,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
|
|
|
217
233
|
both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
|
|
218
234
|
explicit command there.
|
|
219
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
|
+
|
|
220
241
|
## Components
|
|
221
242
|
|
|
222
243
|
| Component | Purpose |
|
|
@@ -227,7 +248,7 @@ explicit command there.
|
|
|
227
248
|
| `/review-memory` | Review freshness, conflicts, and consolidation proposals. |
|
|
228
249
|
| `/archive-handoff` | Explicitly preserve a validated handoff as historical memory. |
|
|
229
250
|
| `memory-provider.py` | Launcher for the `memorykit` provider: stdlib validator, local store, MemPalace adapter, and hook dispatcher. |
|
|
230
|
-
| `src/memorykit/` | The packaged
|
|
251
|
+
| `src/memorykit/` | The packaged `memorykit` engine: contract, validator, provider, MCP server. |
|
|
231
252
|
|
|
232
253
|
## Safety boundaries
|
|
233
254
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "memorykit"
|
|
3
|
-
version = "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 = [
|
|
@@ -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.
|
|
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
|
|
@@ -38,6 +38,11 @@ SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
|
38
38
|
PROVIDER = Path(__file__).resolve().with_name("provider.py")
|
|
39
39
|
CALL_TIMEOUT_SECONDS = 120.0
|
|
40
40
|
MAX_RECORD_BYTES = 32 * 1024
|
|
41
|
+
MAX_FRAME_CHARS = MAX_RECORD_BYTES * 8
|
|
42
|
+
EVIDENCE_ERROR_MARKERS = (
|
|
43
|
+
"referenced source is not a readable file",
|
|
44
|
+
"source_hash does not match the referenced source file",
|
|
45
|
+
)
|
|
41
46
|
|
|
42
47
|
PARSE_ERROR = -32700
|
|
43
48
|
INVALID_REQUEST = -32600
|
|
@@ -70,14 +75,15 @@ TOOLS: list[dict[str, Any]] = [
|
|
|
70
75
|
"required": ["query"],
|
|
71
76
|
"additionalProperties": False,
|
|
72
77
|
},
|
|
78
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": False},
|
|
73
79
|
},
|
|
74
80
|
{
|
|
75
81
|
"name": "memory_capture",
|
|
76
82
|
"description": (
|
|
77
83
|
"Persist a context-kit/memory-v1 record as review: proposed. The "
|
|
78
|
-
"record must cite real evidence
|
|
79
|
-
"against the source file. Proposed records
|
|
80
|
-
"accepts them with the record-state CLI."
|
|
84
|
+
"record must cite real evidence at an absolute source path: its "
|
|
85
|
+
"source_hash is verified against the source file. Proposed records "
|
|
86
|
+
"are inert until a human accepts them with the record-state CLI."
|
|
81
87
|
),
|
|
82
88
|
"inputSchema": {
|
|
83
89
|
"type": "object",
|
|
@@ -94,6 +100,12 @@ TOOLS: list[dict[str, Any]] = [
|
|
|
94
100
|
"required": ["record"],
|
|
95
101
|
"additionalProperties": False,
|
|
96
102
|
},
|
|
103
|
+
"annotations": {
|
|
104
|
+
"readOnlyHint": False,
|
|
105
|
+
"destructiveHint": False,
|
|
106
|
+
"idempotentHint": True,
|
|
107
|
+
"openWorldHint": False,
|
|
108
|
+
},
|
|
97
109
|
},
|
|
98
110
|
{
|
|
99
111
|
"name": "memory_review",
|
|
@@ -106,6 +118,7 @@ TOOLS: list[dict[str, Any]] = [
|
|
|
106
118
|
"properties": {},
|
|
107
119
|
"additionalProperties": False,
|
|
108
120
|
},
|
|
121
|
+
"annotations": {"readOnlyHint": True, "openWorldHint": False},
|
|
109
122
|
},
|
|
110
123
|
]
|
|
111
124
|
|
|
@@ -119,26 +132,10 @@ def _log(message: str) -> None:
|
|
|
119
132
|
print(f"{SERVER_NAME}: {message}", file=sys.stderr, flush=True)
|
|
120
133
|
|
|
121
134
|
|
|
122
|
-
def
|
|
123
|
-
for name in ("CONTEXT_KIT_MEMORY_PROJECT", "CLAUDE_PLUGIN_OPTION_PROJECT"):
|
|
124
|
-
# Portable first, then the Claude userConfig fallback the CLI accepts.
|
|
125
|
-
# Checking only the portable name would make every tool refuse on a
|
|
126
|
-
# normal Claude install configured through the plugin's option.
|
|
127
|
-
project = os.environ.get(name, "").strip()
|
|
128
|
-
if project:
|
|
129
|
-
return project
|
|
130
|
-
raise ToolError(
|
|
131
|
-
"no memory project is configured; set CONTEXT_KIT_MEMORY_PROJECT to "
|
|
132
|
-
"an explicit owner/repository. Memory is never read from or written "
|
|
133
|
-
"to an inferred global store."
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def _run_provider(argv: list[str]) -> str:
|
|
135
|
+
def _run_provider(argv: list[str], *, redact_evidence_errors: bool = False) -> str:
|
|
138
136
|
if not PROVIDER.is_file():
|
|
139
137
|
raise ToolError(f"memory provider script is missing: {PROVIDER}")
|
|
140
|
-
|
|
141
|
-
command = [sys.executable, str(PROVIDER), *argv, "--project", project]
|
|
138
|
+
command = [sys.executable, str(PROVIDER), *argv]
|
|
142
139
|
try:
|
|
143
140
|
result = subprocess.run(
|
|
144
141
|
command,
|
|
@@ -154,23 +151,14 @@ def _run_provider(argv: list[str]) -> str:
|
|
|
154
151
|
raise ToolError(f"memory command could not run: {exc}") from exc
|
|
155
152
|
if result.returncode != 0:
|
|
156
153
|
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
|
154
|
+
if redact_evidence_errors and any(
|
|
155
|
+
marker in detail for marker in EVIDENCE_ERROR_MARKERS
|
|
156
|
+
):
|
|
157
|
+
detail = "memory capture refused: cited source evidence did not validate"
|
|
157
158
|
raise ToolError(detail or f"memory command exited {result.returncode}")
|
|
158
159
|
return result.stdout.decode("utf-8", errors="replace").strip()
|
|
159
160
|
|
|
160
161
|
|
|
161
|
-
def _frontmatter_value(record: str, field: str) -> str | None:
|
|
162
|
-
lines = record.splitlines()
|
|
163
|
-
if not lines or lines[0].strip() != "---":
|
|
164
|
-
return None
|
|
165
|
-
for line in lines[1:]:
|
|
166
|
-
if line.strip() == "---":
|
|
167
|
-
break
|
|
168
|
-
key, separator, value = line.partition(":")
|
|
169
|
-
if separator and key.strip() == field:
|
|
170
|
-
return value.strip()
|
|
171
|
-
return None
|
|
172
|
-
|
|
173
|
-
|
|
174
162
|
def _tool_memory_recall(arguments: dict[str, Any]) -> str:
|
|
175
163
|
query = arguments.get("query")
|
|
176
164
|
if not isinstance(query, str) or not query.strip():
|
|
@@ -192,38 +180,21 @@ def _tool_memory_capture(arguments: dict[str, Any]) -> str:
|
|
|
192
180
|
raise ToolError(
|
|
193
181
|
f"record exceeds {MAX_RECORD_BYTES} bytes; keep a memory atomic"
|
|
194
182
|
)
|
|
195
|
-
review = _frontmatter_value(record, "review")
|
|
196
|
-
if review is None:
|
|
197
|
-
raise ToolError("record is missing flat YAML frontmatter with a `review` field")
|
|
198
|
-
if review != "proposed":
|
|
199
|
-
# `capture` takes the initial state from frontmatter, so without this
|
|
200
|
-
# guard an agent could write `review: accepted` and activate a memory
|
|
201
|
-
# with no human review at all.
|
|
202
|
-
raise ToolError(
|
|
203
|
-
"this surface can only propose memory, but the record declares "
|
|
204
|
-
f"review: {review}. Set `review: proposed` and promote it later "
|
|
205
|
-
"with `memory-provider.py record-state <id> --review accepted "
|
|
206
|
-
"--reason ...` after the evidence has been checked."
|
|
207
|
-
)
|
|
208
|
-
# `validate_memory` verifies `source_hash` only when the source is a
|
|
209
|
-
# *regular file* (`source.is_file()`), so `exists()` here would be a weaker
|
|
210
|
-
# gate than the one it exists to mirror: a record citing a directory would
|
|
211
|
-
# pass this check, skip hash verification entirely, and persist with any
|
|
212
|
-
# 64-character hash and unverifiable provenance. Match the provider.
|
|
213
|
-
source = _frontmatter_value(record, "source")
|
|
214
|
-
if not source:
|
|
215
|
-
raise ToolError("record is missing a `source` field citing its evidence")
|
|
216
|
-
if not Path(source).expanduser().is_file():
|
|
217
|
-
raise ToolError(
|
|
218
|
-
f"the cited source is not a readable file: {source}. A memory must "
|
|
219
|
-
"point at evidence that can be re-read and hashed."
|
|
220
|
-
)
|
|
221
183
|
handle, temporary = tempfile.mkstemp(prefix="memory-capture-", suffix=".md")
|
|
222
184
|
path = Path(temporary)
|
|
223
185
|
try:
|
|
224
186
|
with os.fdopen(handle, "wb") as stream:
|
|
225
187
|
stream.write(raw)
|
|
226
|
-
return _run_provider(
|
|
188
|
+
return _run_provider(
|
|
189
|
+
[
|
|
190
|
+
"capture",
|
|
191
|
+
str(path),
|
|
192
|
+
"--require-review",
|
|
193
|
+
"proposed",
|
|
194
|
+
"--require-absolute-source",
|
|
195
|
+
],
|
|
196
|
+
redact_evidence_errors=True,
|
|
197
|
+
)
|
|
227
198
|
finally:
|
|
228
199
|
path.unlink(missing_ok=True)
|
|
229
200
|
|
|
@@ -326,7 +297,22 @@ def _write(sink: Any, payload: dict[str, Any]) -> None:
|
|
|
326
297
|
def serve(stdin: Any = None, stdout: Any = None) -> int:
|
|
327
298
|
source = stdin if stdin is not None else sys.stdin
|
|
328
299
|
sink = stdout if stdout is not None else sys.stdout
|
|
329
|
-
|
|
300
|
+
while True:
|
|
301
|
+
line = source.readline(MAX_FRAME_CHARS + 1)
|
|
302
|
+
if not line:
|
|
303
|
+
break
|
|
304
|
+
if len(line) > MAX_FRAME_CHARS:
|
|
305
|
+
while line and not line.endswith("\n"):
|
|
306
|
+
line = source.readline(MAX_FRAME_CHARS + 1)
|
|
307
|
+
_write(
|
|
308
|
+
sink,
|
|
309
|
+
_error(
|
|
310
|
+
None,
|
|
311
|
+
INVALID_REQUEST,
|
|
312
|
+
f"JSON-RPC frame exceeds {MAX_FRAME_CHARS} characters",
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
continue
|
|
330
316
|
line = line.strip()
|
|
331
317
|
if not line:
|
|
332
318
|
continue
|
|
@@ -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
|
-
#
|
|
58
|
-
#
|
|
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,11 +443,12 @@ 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(
|
|
444
450
|
"CONTEXT_KIT_MEMORY_PROVIDER",
|
|
451
|
+
"PRODUCTIVITY_SKILLS_MEMORY_PROVIDER",
|
|
445
452
|
"CLAUDE_PLUGIN_OPTION_PROVIDER",
|
|
446
453
|
)
|
|
447
454
|
or "none"
|
|
@@ -453,6 +460,7 @@ def _config(args: argparse.Namespace) -> Config:
|
|
|
453
460
|
getattr(args, "home", None)
|
|
454
461
|
or _first_env(
|
|
455
462
|
"CONTEXT_KIT_MEMORY_HOME",
|
|
463
|
+
"PRODUCTIVITY_SKILLS_MEMORY_HOME",
|
|
456
464
|
"CLAUDE_PLUGIN_OPTION_MEMORY_HOME",
|
|
457
465
|
)
|
|
458
466
|
or "~/.local/share/context-kit/memory"
|
|
@@ -460,14 +468,19 @@ def _config(args: argparse.Namespace) -> Config:
|
|
|
460
468
|
home = Path(home_value).expanduser().resolve()
|
|
461
469
|
project = getattr(args, "project", None) or _first_env(
|
|
462
470
|
"CONTEXT_KIT_MEMORY_PROJECT",
|
|
471
|
+
"PRODUCTIVITY_SKILLS_MEMORY_PROJECT",
|
|
463
472
|
"CLAUDE_PLUGIN_OPTION_PROJECT",
|
|
464
473
|
)
|
|
474
|
+
if not project and allow_session_binding:
|
|
475
|
+
project = _copilot_session_binding_project(home)
|
|
465
476
|
auto_value = _first_env(
|
|
466
477
|
"CONTEXT_KIT_MEMORY_AUTO_CAPTURE",
|
|
478
|
+
"PRODUCTIVITY_SKILLS_MEMORY_AUTO_CAPTURE",
|
|
467
479
|
"CLAUDE_PLUGIN_OPTION_AUTO_CAPTURE",
|
|
468
480
|
)
|
|
469
481
|
recall_value = _first_env(
|
|
470
482
|
"CONTEXT_KIT_MEMORY_RECALL_ON_START",
|
|
483
|
+
"PRODUCTIVITY_SKILLS_MEMORY_RECALL_ON_START",
|
|
471
484
|
"CLAUDE_PLUGIN_OPTION_RECALL_ON_START",
|
|
472
485
|
)
|
|
473
486
|
return Config(
|
|
@@ -611,7 +624,13 @@ def _read_bounded(
|
|
|
611
624
|
return raw, text
|
|
612
625
|
|
|
613
626
|
|
|
614
|
-
def validate_memory(
|
|
627
|
+
def validate_memory(
|
|
628
|
+
path: Path,
|
|
629
|
+
*,
|
|
630
|
+
verify_source: bool = True,
|
|
631
|
+
require_review: str | None = None,
|
|
632
|
+
require_absolute_source: bool = False,
|
|
633
|
+
) -> dict[str, object]:
|
|
615
634
|
raw, text = _read_bounded(path)
|
|
616
635
|
fields, body = _parse_frontmatter(text)
|
|
617
636
|
missing = [field for field in REQUIRED_FIELDS if field not in fields]
|
|
@@ -633,6 +652,11 @@ def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, obje
|
|
|
633
652
|
raise Refusal(f"freshness must be one of {sorted(FRESHNESS_STATES)}")
|
|
634
653
|
if fields["review"] not in REVIEW_STATES:
|
|
635
654
|
raise Refusal(f"review must be one of {sorted(REVIEW_STATES)}")
|
|
655
|
+
if require_review is not None and fields["review"] != require_review:
|
|
656
|
+
raise Refusal(
|
|
657
|
+
f"memory review must be {require_review!r} for this operation; "
|
|
658
|
+
f"got {fields['review']!r}"
|
|
659
|
+
)
|
|
636
660
|
if not HASH_RE.fullmatch(fields["source_hash"]):
|
|
637
661
|
raise Refusal("source_hash must be a lowercase SHA-256 digest")
|
|
638
662
|
_validate_timestamp(fields["observed_at"], "observed_at")
|
|
@@ -666,8 +690,18 @@ def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, obje
|
|
|
666
690
|
_nonempty_section(sections["## Review Notes"], "Review Notes")
|
|
667
691
|
|
|
668
692
|
source = Path(fields["source"]).expanduser()
|
|
669
|
-
if
|
|
670
|
-
|
|
693
|
+
if require_absolute_source and not source.is_absolute():
|
|
694
|
+
raise Refusal("memory source must be an absolute path for this operation")
|
|
695
|
+
if verify_source:
|
|
696
|
+
if not source.is_file():
|
|
697
|
+
raise Refusal(f"referenced source is not a readable file: {source}")
|
|
698
|
+
try:
|
|
699
|
+
source_bytes = source.read_bytes()
|
|
700
|
+
except OSError as exc:
|
|
701
|
+
raise Refusal(
|
|
702
|
+
f"referenced source is not a readable file: {source}"
|
|
703
|
+
) from exc
|
|
704
|
+
actual = hashlib.sha256(source_bytes).hexdigest()
|
|
671
705
|
if actual != fields["source_hash"]:
|
|
672
706
|
raise Refusal("source_hash does not match the referenced source file")
|
|
673
707
|
return {
|
|
@@ -746,6 +780,288 @@ def _normalize_repository(remote: str) -> str:
|
|
|
746
780
|
return "/".join(parts[-2:])
|
|
747
781
|
|
|
748
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
|
+
|
|
749
1065
|
def _assert_project_matches(metadata: dict[str, object], config: Config) -> None:
|
|
750
1066
|
project = config.project
|
|
751
1067
|
if not project:
|
|
@@ -1407,7 +1723,11 @@ def _provider_version(config: Config) -> tuple[str, str]:
|
|
|
1407
1723
|
|
|
1408
1724
|
def _capture_memory(args: argparse.Namespace, config: Config) -> int:
|
|
1409
1725
|
source = Path(args.artifact).expanduser().resolve()
|
|
1410
|
-
metadata = validate_memory(
|
|
1726
|
+
metadata = validate_memory(
|
|
1727
|
+
source,
|
|
1728
|
+
require_review=args.require_review,
|
|
1729
|
+
require_absolute_source=args.require_absolute_source,
|
|
1730
|
+
)
|
|
1411
1731
|
_assert_project_matches(metadata, config)
|
|
1412
1732
|
raw = source.read_bytes()
|
|
1413
1733
|
destination = config.records_path / f"{metadata['id']}.md"
|
|
@@ -2709,32 +3029,86 @@ def _hook_recall(config: Config) -> int:
|
|
|
2709
3029
|
return 0
|
|
2710
3030
|
|
|
2711
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
|
+
|
|
2712
3054
|
def _run_hook(event: str, config: Config, payload: bytes) -> int:
|
|
2713
3055
|
if event == "session-start":
|
|
2714
|
-
return
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
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] = {}
|
|
2718
3085
|
try:
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
json.dumps(
|
|
2730
|
-
{
|
|
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 = {
|
|
2731
3096
|
"status": "queued-for-review",
|
|
2732
3097
|
"event": event,
|
|
2733
3098
|
"pending": str(pending),
|
|
2734
3099
|
"provider_invoked": False,
|
|
2735
3100
|
}
|
|
2736
|
-
|
|
2737
|
-
|
|
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))
|
|
2738
3112
|
return 0
|
|
2739
3113
|
|
|
2740
3114
|
|
|
@@ -2761,6 +3135,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
2761
3135
|
capture = sub.add_parser("capture")
|
|
2762
3136
|
capture.add_argument("artifact")
|
|
2763
3137
|
capture.add_argument("--local-only", action="store_true")
|
|
3138
|
+
capture.add_argument("--require-review", choices=sorted(REVIEW_STATES))
|
|
3139
|
+
capture.add_argument("--require-absolute-source", action="store_true")
|
|
2764
3140
|
_add_config_args(capture)
|
|
2765
3141
|
|
|
2766
3142
|
archive = sub.add_parser("archive-handoff")
|
|
@@ -2870,7 +3246,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2870
3246
|
print(json.dumps({"status": "valid", "id": metadata["id"]}))
|
|
2871
3247
|
return 0
|
|
2872
3248
|
|
|
2873
|
-
|
|
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)
|
|
2874
3253
|
if args.command == "capture":
|
|
2875
3254
|
return _capture_memory(args, config)
|
|
2876
3255
|
if args.command == "archive-handoff":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|