memorykit 0.6.0__tar.gz → 0.7.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.7.0}/CHANGELOG.md +18 -0
- {memorykit-0.6.0 → memorykit-0.7.0}/PKG-INFO +23 -17
- {memorykit-0.6.0 → memorykit-0.7.0}/README.md +21 -15
- {memorykit-0.6.0 → memorykit-0.7.0}/pyproject.toml +1 -1
- {memorykit-0.6.0 → memorykit-0.7.0}/src/memorykit/__init__.py +1 -1
- {memorykit-0.6.0 → memorykit-0.7.0}/src/memorykit/mcp.py +49 -63
- {memorykit-0.6.0 → memorykit-0.7.0}/src/memorykit/provider.py +36 -4
- {memorykit-0.6.0 → memorykit-0.7.0}/.gitignore +0 -0
- {memorykit-0.6.0 → memorykit-0.7.0}/LICENSE +0 -0
- {memorykit-0.6.0 → memorykit-0.7.0}/src/memorykit/__main__.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 — 2026-08-29
|
|
4
|
+
|
|
5
|
+
- Make plugin-installed MCP safe and configurable in GitHub Copilot: declare
|
|
6
|
+
`stdio` explicitly and forward the operator-set
|
|
7
|
+
`CONTEXT_KIT_MEMORY_PROJECT` (or deprecated
|
|
8
|
+
`PRODUCTIVITY_SKILLS_MEMORY_PROJECT`) into the child process. The server still
|
|
9
|
+
refuses when no explicit scope is configured; prompt content cannot select
|
|
10
|
+
another project store.
|
|
11
|
+
- Move MCP proposal-only and absolute-source enforcement into the provider, so
|
|
12
|
+
quoted frontmatter follows the same parser as CLI capture and policy cannot
|
|
13
|
+
drift between surfaces. Provider validation now refuses a missing or
|
|
14
|
+
non-regular evidence source instead of skipping hash verification.
|
|
15
|
+
- Bound incoming JSON-RPC frames before decoding and reconcile the MCP server
|
|
16
|
+
version in packaging tests.
|
|
17
|
+
- Add MCP tool safety annotations and collapse missing-source versus
|
|
18
|
+
hash-mismatch failures into one capture refusal, avoiding a path-existence
|
|
19
|
+
distinction while preserving explicit failure.
|
|
20
|
+
|
|
3
21
|
## 0.6.0 — 2026-08-09
|
|
4
22
|
|
|
5
23
|
- **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.7.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 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,14 @@ 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 `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.
|
|
247
|
+
|
|
242
248
|
The surface can propose memory but **cannot activate it**: a record whose
|
|
243
249
|
frontmatter is not `review: proposed` is refused, and proposals stay out of
|
|
244
250
|
active recall until promoted with the append-only `record-state` CLI.
|
|
@@ -270,7 +276,7 @@ explicit command there.
|
|
|
270
276
|
| `/review-memory` | Review freshness, conflicts, and consolidation proposals. |
|
|
271
277
|
| `/archive-handoff` | Explicitly preserve a validated handoff as historical memory. |
|
|
272
278
|
| `memory-provider.py` | Launcher for the `memorykit` provider: stdlib validator, local store, MemPalace adapter, and hook dispatcher. |
|
|
273
|
-
| `src/memorykit/` | The packaged
|
|
279
|
+
| `src/memorykit/` | The packaged `memorykit` engine: contract, validator, provider, MCP server. |
|
|
274
280
|
|
|
275
281
|
## Safety boundaries
|
|
276
282
|
|
|
@@ -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 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,14 @@ 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 `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.
|
|
204
|
+
|
|
199
205
|
The surface can propose memory but **cannot activate it**: a record whose
|
|
200
206
|
frontmatter is not `review: proposed` is refused, and proposals stay out of
|
|
201
207
|
active recall until promoted with the append-only `record-state` CLI.
|
|
@@ -227,7 +233,7 @@ explicit command there.
|
|
|
227
233
|
| `/review-memory` | Review freshness, conflicts, and consolidation proposals. |
|
|
228
234
|
| `/archive-handoff` | Explicitly preserve a validated handoff as historical memory. |
|
|
229
235
|
| `memory-provider.py` | Launcher for the `memorykit` provider: stdlib validator, local store, MemPalace adapter, and hook dispatcher. |
|
|
230
|
-
| `src/memorykit/` | The packaged
|
|
236
|
+
| `src/memorykit/` | The packaged `memorykit` engine: contract, validator, provider, MCP server. |
|
|
231
237
|
|
|
232
238
|
## Safety boundaries
|
|
233
239
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "memorykit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.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.7.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
|
|
@@ -442,6 +442,7 @@ def _config(args: argparse.Namespace) -> Config:
|
|
|
442
442
|
getattr(args, "provider", None)
|
|
443
443
|
or _first_env(
|
|
444
444
|
"CONTEXT_KIT_MEMORY_PROVIDER",
|
|
445
|
+
"PRODUCTIVITY_SKILLS_MEMORY_PROVIDER",
|
|
445
446
|
"CLAUDE_PLUGIN_OPTION_PROVIDER",
|
|
446
447
|
)
|
|
447
448
|
or "none"
|
|
@@ -453,6 +454,7 @@ def _config(args: argparse.Namespace) -> Config:
|
|
|
453
454
|
getattr(args, "home", None)
|
|
454
455
|
or _first_env(
|
|
455
456
|
"CONTEXT_KIT_MEMORY_HOME",
|
|
457
|
+
"PRODUCTIVITY_SKILLS_MEMORY_HOME",
|
|
456
458
|
"CLAUDE_PLUGIN_OPTION_MEMORY_HOME",
|
|
457
459
|
)
|
|
458
460
|
or "~/.local/share/context-kit/memory"
|
|
@@ -460,14 +462,17 @@ def _config(args: argparse.Namespace) -> Config:
|
|
|
460
462
|
home = Path(home_value).expanduser().resolve()
|
|
461
463
|
project = getattr(args, "project", None) or _first_env(
|
|
462
464
|
"CONTEXT_KIT_MEMORY_PROJECT",
|
|
465
|
+
"PRODUCTIVITY_SKILLS_MEMORY_PROJECT",
|
|
463
466
|
"CLAUDE_PLUGIN_OPTION_PROJECT",
|
|
464
467
|
)
|
|
465
468
|
auto_value = _first_env(
|
|
466
469
|
"CONTEXT_KIT_MEMORY_AUTO_CAPTURE",
|
|
470
|
+
"PRODUCTIVITY_SKILLS_MEMORY_AUTO_CAPTURE",
|
|
467
471
|
"CLAUDE_PLUGIN_OPTION_AUTO_CAPTURE",
|
|
468
472
|
)
|
|
469
473
|
recall_value = _first_env(
|
|
470
474
|
"CONTEXT_KIT_MEMORY_RECALL_ON_START",
|
|
475
|
+
"PRODUCTIVITY_SKILLS_MEMORY_RECALL_ON_START",
|
|
471
476
|
"CLAUDE_PLUGIN_OPTION_RECALL_ON_START",
|
|
472
477
|
)
|
|
473
478
|
return Config(
|
|
@@ -611,7 +616,13 @@ def _read_bounded(
|
|
|
611
616
|
return raw, text
|
|
612
617
|
|
|
613
618
|
|
|
614
|
-
def validate_memory(
|
|
619
|
+
def validate_memory(
|
|
620
|
+
path: Path,
|
|
621
|
+
*,
|
|
622
|
+
verify_source: bool = True,
|
|
623
|
+
require_review: str | None = None,
|
|
624
|
+
require_absolute_source: bool = False,
|
|
625
|
+
) -> dict[str, object]:
|
|
615
626
|
raw, text = _read_bounded(path)
|
|
616
627
|
fields, body = _parse_frontmatter(text)
|
|
617
628
|
missing = [field for field in REQUIRED_FIELDS if field not in fields]
|
|
@@ -633,6 +644,11 @@ def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, obje
|
|
|
633
644
|
raise Refusal(f"freshness must be one of {sorted(FRESHNESS_STATES)}")
|
|
634
645
|
if fields["review"] not in REVIEW_STATES:
|
|
635
646
|
raise Refusal(f"review must be one of {sorted(REVIEW_STATES)}")
|
|
647
|
+
if require_review is not None and fields["review"] != require_review:
|
|
648
|
+
raise Refusal(
|
|
649
|
+
f"memory review must be {require_review!r} for this operation; "
|
|
650
|
+
f"got {fields['review']!r}"
|
|
651
|
+
)
|
|
636
652
|
if not HASH_RE.fullmatch(fields["source_hash"]):
|
|
637
653
|
raise Refusal("source_hash must be a lowercase SHA-256 digest")
|
|
638
654
|
_validate_timestamp(fields["observed_at"], "observed_at")
|
|
@@ -666,8 +682,18 @@ def validate_memory(path: Path, *, verify_source: bool = True) -> dict[str, obje
|
|
|
666
682
|
_nonempty_section(sections["## Review Notes"], "Review Notes")
|
|
667
683
|
|
|
668
684
|
source = Path(fields["source"]).expanduser()
|
|
669
|
-
if
|
|
670
|
-
|
|
685
|
+
if require_absolute_source and not source.is_absolute():
|
|
686
|
+
raise Refusal("memory source must be an absolute path for this operation")
|
|
687
|
+
if verify_source:
|
|
688
|
+
if not source.is_file():
|
|
689
|
+
raise Refusal(f"referenced source is not a readable file: {source}")
|
|
690
|
+
try:
|
|
691
|
+
source_bytes = source.read_bytes()
|
|
692
|
+
except OSError as exc:
|
|
693
|
+
raise Refusal(
|
|
694
|
+
f"referenced source is not a readable file: {source}"
|
|
695
|
+
) from exc
|
|
696
|
+
actual = hashlib.sha256(source_bytes).hexdigest()
|
|
671
697
|
if actual != fields["source_hash"]:
|
|
672
698
|
raise Refusal("source_hash does not match the referenced source file")
|
|
673
699
|
return {
|
|
@@ -1407,7 +1433,11 @@ def _provider_version(config: Config) -> tuple[str, str]:
|
|
|
1407
1433
|
|
|
1408
1434
|
def _capture_memory(args: argparse.Namespace, config: Config) -> int:
|
|
1409
1435
|
source = Path(args.artifact).expanduser().resolve()
|
|
1410
|
-
metadata = validate_memory(
|
|
1436
|
+
metadata = validate_memory(
|
|
1437
|
+
source,
|
|
1438
|
+
require_review=args.require_review,
|
|
1439
|
+
require_absolute_source=args.require_absolute_source,
|
|
1440
|
+
)
|
|
1411
1441
|
_assert_project_matches(metadata, config)
|
|
1412
1442
|
raw = source.read_bytes()
|
|
1413
1443
|
destination = config.records_path / f"{metadata['id']}.md"
|
|
@@ -2761,6 +2791,8 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
2761
2791
|
capture = sub.add_parser("capture")
|
|
2762
2792
|
capture.add_argument("artifact")
|
|
2763
2793
|
capture.add_argument("--local-only", action="store_true")
|
|
2794
|
+
capture.add_argument("--require-review", choices=sorted(REVIEW_STATES))
|
|
2795
|
+
capture.add_argument("--require-absolute-source", action="store_true")
|
|
2764
2796
|
_add_config_args(capture)
|
|
2765
2797
|
|
|
2766
2798
|
archive = sub.add_parser("archive-handoff")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|