packwright 0.1.0__py3-none-any.whl
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.
- packwright/__init__.py +3 -0
- packwright/__main__.py +5 -0
- packwright/adapters/__init__.py +10 -0
- packwright/adapters/claude_code.py +377 -0
- packwright/adapters/codex.py +311 -0
- packwright/adapters/cursor.py +375 -0
- packwright/checker/__init__.py +3 -0
- packwright/checker/scoring.py +924 -0
- packwright/cli.py +971 -0
- packwright/core/__init__.py +57 -0
- packwright/core/adapter_layout.py +35 -0
- packwright/core/adopt.py +199 -0
- packwright/core/character_intake.py +1649 -0
- packwright/core/emotion_engine_contract.py +147 -0
- packwright/core/errors.py +13 -0
- packwright/core/handoff.py +531 -0
- packwright/core/install.py +2114 -0
- packwright/core/intake_contract.py +105 -0
- packwright/core/knowledge_contract.py +212 -0
- packwright/core/loader.py +35 -0
- packwright/core/memory_projection.py +126 -0
- packwright/core/naming.py +98 -0
- packwright/core/pack_metadata.py +100 -0
- packwright/core/path_safety.py +70 -0
- packwright/core/resolver.py +66 -0
- packwright/core/validation.py +645 -0
- packwright/core/workspace_contract.py +102 -0
- packwright-0.1.0.dist-info/METADATA +213 -0
- packwright-0.1.0.dist-info/RECORD +33 -0
- packwright-0.1.0.dist-info/WHEEL +5 -0
- packwright-0.1.0.dist-info/entry_points.txt +2 -0
- packwright-0.1.0.dist-info/licenses/LICENSE +21 -0
- packwright-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
INTERVIEWER_PROMPT_TEMPLATE = """# Packwright Character Draft Interviewer
|
|
5
|
+
|
|
6
|
+
You are the LLM intake normalizer for Packwright.
|
|
7
|
+
|
|
8
|
+
Your job is to interview the user and produce one canonical `CharacterIntake` YAML document. Do not generate adapter files yourself. The deterministic compiler will do that after the user confirms the YAML.
|
|
9
|
+
|
|
10
|
+
## Interview Rules
|
|
11
|
+
|
|
12
|
+
- Ask one concise question at a time.
|
|
13
|
+
- Do not use a fixed questionnaire. Ask only what is needed for this user's character.
|
|
14
|
+
- If an answer is unrelated, ambiguous, too broad, or only partially answers the question, ask a targeted follow-up.
|
|
15
|
+
- Normalize casual wording into clean fields. For example, "叫 Alice 吧" should become `name: Alice`, not the full phrase.
|
|
16
|
+
- Preserve the user's intent and flavor, but make the YAML professional and compilable.
|
|
17
|
+
- Keep relationship, durable memory, and runtime state separate.
|
|
18
|
+
- Map the character to an archetype only when clear; otherwise default to `productivity`.
|
|
19
|
+
- Do not ask users to choose implementation terms such as Emotion Engine, light, always, or paused.
|
|
20
|
+
- Ask relationship continuity in plain language: "你希望这个角色的关系连续性到什么程度?A. 只做事,不维护情绪关系;B. 有温度,但只记重要偏好;C. 更像长期陪伴,会持续记住相处细节。"
|
|
21
|
+
- If the user asks what the choice means internally, explain the rough cost: B is lightweight selective continuity, while C is more active continuity and can use more context over time.
|
|
22
|
+
- Do not overfit the character into a generic assistant. The result should feel like a specific working presence.
|
|
23
|
+
- Before finalizing, show a short summary and ask the user to confirm or correct it.
|
|
24
|
+
|
|
25
|
+
## Minimum Information To Resolve
|
|
26
|
+
|
|
27
|
+
You need enough information to fill:
|
|
28
|
+
|
|
29
|
+
- `name`: short character name.
|
|
30
|
+
- `slug`: lowercase ASCII path slug; use a readable transliteration or short English handle when `name` is not Latin script.
|
|
31
|
+
- `user_name`: how the character should refer to the user.
|
|
32
|
+
- `relationship`: compact relationship label, such as work partner, editor, coach, research partner, secretary, companion-style partner.
|
|
33
|
+
- `archetype`: one of `productivity`, `learning-coach`, `companion`, `creator`, or `operations`.
|
|
34
|
+
- `role`: one clear sentence describing what this character is for.
|
|
35
|
+
- `primary_work`: 2-6 concrete work areas.
|
|
36
|
+
- `voice`: compact style description.
|
|
37
|
+
- `avoid`: concrete tones or behaviors to avoid.
|
|
38
|
+
- `traits`: 2-6 stable traits.
|
|
39
|
+
- `relationship_continuity`: one of `task_only`, `warm_selective`, or `close_continuous`.
|
|
40
|
+
|
|
41
|
+
Default `user_name` to `{user_name}` if the user does not specify another name.
|
|
42
|
+
|
|
43
|
+
## Field Guidance
|
|
44
|
+
|
|
45
|
+
- `name` should be just the name. Remove phrases like "叫", "吧", "let's call her", "maybe".
|
|
46
|
+
- `slug` should use lowercase letters, numbers, and hyphens only, such as `system` or `media-editor`.
|
|
47
|
+
- `relationship` should not include "is my" or other sentence fragments.
|
|
48
|
+
- `archetype` should describe the default memory and work pattern, not the personality. Use `creator` for media/content roles, `learning-coach` for teaching/training, `companion` for relationship-oriented continuity, `operations` for recurring maintenance, and `productivity` otherwise.
|
|
49
|
+
- `role` should be grammatical and specific.
|
|
50
|
+
- `primary_work` should be concrete tasks, not vague traits.
|
|
51
|
+
- `voice` should include positive style guidance.
|
|
52
|
+
- `avoid` should include negative style guidance and boundaries.
|
|
53
|
+
- `traits` should be stable character traits, not current tasks.
|
|
54
|
+
- If the user wants only practical task execution, choose `task_only`.
|
|
55
|
+
- If the user wants warmth, reminders, care, or light teasing but only important preferences remembered, choose `warm_selective`.
|
|
56
|
+
- If the user wants stronger long-term companionship and ongoing relationship continuity, choose `close_continuous`.
|
|
57
|
+
- If the user is unsure, choose `warm_selective` as the low-risk default.
|
|
58
|
+
- Do not add runtime mode fields to the YAML. The deterministic compiler maps relationship continuity to the appropriate runtime behavior.
|
|
59
|
+
|
|
60
|
+
## Output Contract
|
|
61
|
+
|
|
62
|
+
After confirmation, output only this YAML shape:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
version: "0.1"
|
|
66
|
+
kind: CharacterIntake
|
|
67
|
+
character:
|
|
68
|
+
name: Alice
|
|
69
|
+
slug: alice
|
|
70
|
+
user_name: {user_name}
|
|
71
|
+
relationship: media work partner
|
|
72
|
+
archetype: creator
|
|
73
|
+
role: "{user_name}'s media planning and publishing work partner."
|
|
74
|
+
voice: direct, proactive about risks, occasionally sharp and playful, but not cruel
|
|
75
|
+
avoid:
|
|
76
|
+
- bland assistant tone
|
|
77
|
+
- excessive politeness
|
|
78
|
+
- mechanical audit-log replies
|
|
79
|
+
- cruelty or personal attacks
|
|
80
|
+
primary_work:
|
|
81
|
+
- plan media topics
|
|
82
|
+
- polish copy
|
|
83
|
+
- develop cover and title ideas
|
|
84
|
+
- prepare content for final publishing
|
|
85
|
+
relationship_continuity: warm_selective
|
|
86
|
+
traits:
|
|
87
|
+
- direct
|
|
88
|
+
- perceptive
|
|
89
|
+
- playful
|
|
90
|
+
- editorially practical
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Do not include Markdown around the final YAML unless the user asks.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_interviewer_prompt(user_name="the user"):
|
|
98
|
+
return INTERVIEWER_PROMPT_TEMPLATE.format(user_name=user_name or "the user")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_interviewer_prompt(path, user_name="the user"):
|
|
102
|
+
output_path = Path(path)
|
|
103
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
output_path.write_text(render_interviewer_prompt(user_name=user_name), encoding="utf-8")
|
|
105
|
+
return output_path
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
KNOWLEDGE_ROOT = "knowledge"
|
|
6
|
+
SOURCES_ROOT = "sources"
|
|
7
|
+
KNOWLEDGE_INDEX = "knowledge/index.md"
|
|
8
|
+
KNOWLEDGE_MANIFEST = "knowledge/manifest.json"
|
|
9
|
+
SOURCE_MANIFESTS = (
|
|
10
|
+
"sources/local/manifest.json",
|
|
11
|
+
"sources/notion/manifest.json",
|
|
12
|
+
"sources/repos/manifest.json",
|
|
13
|
+
"sources/web/manifest.json",
|
|
14
|
+
)
|
|
15
|
+
KNOWLEDGE_SCHEMA = "packwright-knowledge-manifest/v1"
|
|
16
|
+
SOURCE_SCHEMA = "packwright-source-manifest/v1"
|
|
17
|
+
MAX_KNOWLEDGE_INDEX_LINES = 200
|
|
18
|
+
MAX_DOMAIN_INDEX_LINES = 300
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def knowledge_required_dirs():
|
|
22
|
+
return (
|
|
23
|
+
KNOWLEDGE_ROOT,
|
|
24
|
+
SOURCES_ROOT,
|
|
25
|
+
"sources/local",
|
|
26
|
+
"sources/notion",
|
|
27
|
+
"sources/repos",
|
|
28
|
+
"sources/web",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def knowledge_artifacts():
|
|
33
|
+
return (KNOWLEDGE_INDEX, KNOWLEDGE_MANIFEST, *SOURCE_MANIFESTS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def knowledge_files():
|
|
37
|
+
files = {
|
|
38
|
+
KNOWLEDGE_INDEX: knowledge_index_text(),
|
|
39
|
+
KNOWLEDGE_MANIFEST: json.dumps(empty_knowledge_manifest(), indent=2, sort_keys=True) + "\n",
|
|
40
|
+
}
|
|
41
|
+
for rel_path in SOURCE_MANIFESTS:
|
|
42
|
+
provider = Path(rel_path).parent.name
|
|
43
|
+
files[rel_path] = json.dumps(empty_source_manifest(provider), indent=2, sort_keys=True) + "\n"
|
|
44
|
+
return files
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def knowledge_index_text():
|
|
48
|
+
return (
|
|
49
|
+
"# Knowledge Recall Index\n\n"
|
|
50
|
+
"Use this file only when a request needs reusable domain knowledge beyond current project state, todos, or source lookup.\n\n"
|
|
51
|
+
"Do not load every note by default. Use this file as a short routing block, then read the selected domain index or note.\n\n"
|
|
52
|
+
"## Loading Rules\n\n"
|
|
53
|
+
"1. Read `memory/index.md` first for local memory routing.\n"
|
|
54
|
+
"2. If the request needs reusable models, principles, or domain patterns, read this file.\n"
|
|
55
|
+
"3. Match the request against domain entries below.\n"
|
|
56
|
+
"4. Read the smallest useful note set.\n"
|
|
57
|
+
"5. Follow `source_refs` only when factual evidence, citation, or source verification matters.\n\n"
|
|
58
|
+
"## Domains\n\n"
|
|
59
|
+
"No reviewed knowledge notes have been recorded yet.\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def empty_knowledge_manifest():
|
|
64
|
+
return {
|
|
65
|
+
"schema": KNOWLEDGE_SCHEMA,
|
|
66
|
+
"generated": False,
|
|
67
|
+
"updated": None,
|
|
68
|
+
"notes": [],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def empty_source_manifest(provider):
|
|
73
|
+
return {
|
|
74
|
+
"schema": SOURCE_SCHEMA,
|
|
75
|
+
"provider": provider,
|
|
76
|
+
"updated": None,
|
|
77
|
+
"sources": {},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def knowledge_feature():
|
|
82
|
+
return {
|
|
83
|
+
"root": KNOWLEDGE_ROOT,
|
|
84
|
+
"recall_index": KNOWLEDGE_INDEX,
|
|
85
|
+
"manifest": KNOWLEDGE_MANIFEST,
|
|
86
|
+
"sources_root": SOURCES_ROOT,
|
|
87
|
+
"source_manifests": list(SOURCE_MANIFESTS),
|
|
88
|
+
"loading_policy": "explicit_gate_then_smallest_useful_note_set",
|
|
89
|
+
"status": "scaffold",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def knowledge_entry_lines(prefix=""):
|
|
94
|
+
return [
|
|
95
|
+
f"{prefix}Read `knowledge/index.md` only when the task needs reusable domain knowledge beyond current memory or source lookup.",
|
|
96
|
+
f"{prefix}Use `knowledge/**/*.md` for reviewed reusable models and patterns; do not put current project status or todos there.",
|
|
97
|
+
f"{prefix}Use `sources/*/manifest.json` for provenance; source manifests are not runtime knowledge bodies.",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def knowledge_manifest_diagnostics(root_dir):
|
|
102
|
+
root_dir = Path(root_dir)
|
|
103
|
+
issues = []
|
|
104
|
+
manifest_path = root_dir / KNOWLEDGE_MANIFEST
|
|
105
|
+
manifest = _read_json(manifest_path, issues, "knowledge_manifest_invalid")
|
|
106
|
+
source_manifests = {}
|
|
107
|
+
for rel_path in SOURCE_MANIFESTS:
|
|
108
|
+
data = _read_json(root_dir / rel_path, issues, "source_manifest_invalid")
|
|
109
|
+
if isinstance(data, dict):
|
|
110
|
+
source_manifests[Path(rel_path).parent.name] = data.get("sources", {})
|
|
111
|
+
if isinstance(manifest, dict):
|
|
112
|
+
issues.extend(_knowledge_notes_diagnostics(root_dir, manifest, source_manifests))
|
|
113
|
+
issues.extend(_knowledge_index_diagnostics(root_dir))
|
|
114
|
+
return issues
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _knowledge_notes_diagnostics(root_dir, manifest, source_manifests):
|
|
118
|
+
issues = []
|
|
119
|
+
notes = manifest.get("notes", [])
|
|
120
|
+
if not isinstance(notes, list):
|
|
121
|
+
return [_issue("knowledge_manifest_notes_invalid", KNOWLEDGE_MANIFEST, "knowledge manifest notes must be a list")]
|
|
122
|
+
for note in notes:
|
|
123
|
+
if not isinstance(note, dict):
|
|
124
|
+
issues.append(_issue("knowledge_manifest_note_invalid", KNOWLEDGE_MANIFEST, "knowledge manifest notes must be objects"))
|
|
125
|
+
continue
|
|
126
|
+
path = note.get("path")
|
|
127
|
+
if not isinstance(path, str) or not path.startswith("knowledge/") or ".." in Path(path).parts:
|
|
128
|
+
issues.append(_issue("knowledge_note_path_invalid", KNOWLEDGE_MANIFEST, "knowledge note path must stay under knowledge/"))
|
|
129
|
+
continue
|
|
130
|
+
note_path = root_dir / path
|
|
131
|
+
if not note_path.is_file():
|
|
132
|
+
issues.append(_issue("knowledge_note_missing", path, "knowledge note listed in manifest is missing"))
|
|
133
|
+
continue
|
|
134
|
+
frontmatter = _frontmatter(note_path)
|
|
135
|
+
for field in ("id", "domain", "type", "status", "source_refs"):
|
|
136
|
+
if field not in frontmatter:
|
|
137
|
+
issues.append(_issue("knowledge_note_frontmatter_missing", path, f"knowledge note frontmatter missing {field}"))
|
|
138
|
+
for ref in _note_source_refs(frontmatter):
|
|
139
|
+
provider, _, key = ref.partition(":")
|
|
140
|
+
if not provider or not key or key not in source_manifests.get(provider, {}):
|
|
141
|
+
issues.append(_issue("knowledge_source_ref_unresolved", path, f"source ref is unresolved: {ref}"))
|
|
142
|
+
return issues
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _knowledge_index_diagnostics(root_dir):
|
|
146
|
+
issues = []
|
|
147
|
+
index_path = root_dir / KNOWLEDGE_INDEX
|
|
148
|
+
if not index_path.is_file():
|
|
149
|
+
return issues
|
|
150
|
+
lines = index_path.read_text(encoding="utf-8").splitlines()
|
|
151
|
+
if len(lines) > MAX_KNOWLEDGE_INDEX_LINES:
|
|
152
|
+
issues.append(_issue("knowledge_recall_index_too_long", KNOWLEDGE_INDEX, "knowledge recall index is too long"))
|
|
153
|
+
for line in lines:
|
|
154
|
+
marker = "Note: `"
|
|
155
|
+
if marker not in line:
|
|
156
|
+
continue
|
|
157
|
+
rel_path = line.split(marker, 1)[1].split("`", 1)[0]
|
|
158
|
+
if rel_path and not (root_dir / rel_path).is_file():
|
|
159
|
+
issues.append(_issue("knowledge_index_note_missing", KNOWLEDGE_INDEX, f"index points to missing note: {rel_path}"))
|
|
160
|
+
for domain_index in (root_dir / KNOWLEDGE_ROOT).glob("*/index.md"):
|
|
161
|
+
count = len(domain_index.read_text(encoding="utf-8").splitlines())
|
|
162
|
+
if count > MAX_DOMAIN_INDEX_LINES:
|
|
163
|
+
issues.append(_issue("knowledge_domain_index_too_long", str(domain_index.relative_to(root_dir)), "knowledge domain index is too long"))
|
|
164
|
+
return issues
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_json(path, issues, issue_id):
|
|
168
|
+
if not path.is_file():
|
|
169
|
+
issues.append(_issue("knowledge_scaffold_missing_file", str(path), "required knowledge scaffold file is missing"))
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
173
|
+
except json.JSONDecodeError as exc:
|
|
174
|
+
issues.append(_issue(issue_id, str(path), f"invalid JSON: {exc}"))
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _frontmatter(path):
|
|
179
|
+
text = path.read_text(encoding="utf-8")
|
|
180
|
+
if not text.startswith("---\n"):
|
|
181
|
+
return {}
|
|
182
|
+
end = text.find("\n---", 4)
|
|
183
|
+
if end == -1:
|
|
184
|
+
return {}
|
|
185
|
+
result = {}
|
|
186
|
+
current = None
|
|
187
|
+
for raw_line in text[4:end].splitlines():
|
|
188
|
+
line = raw_line.rstrip()
|
|
189
|
+
if not line:
|
|
190
|
+
continue
|
|
191
|
+
if line.startswith(" - ") and current:
|
|
192
|
+
result.setdefault(current, []).append(line[4:].strip())
|
|
193
|
+
continue
|
|
194
|
+
if ":" in line:
|
|
195
|
+
key, value = line.split(":", 1)
|
|
196
|
+
current = key.strip()
|
|
197
|
+
value = value.strip()
|
|
198
|
+
result[current] = value if value else []
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _note_source_refs(frontmatter):
|
|
203
|
+
refs = frontmatter.get("source_refs", [])
|
|
204
|
+
if isinstance(refs, list):
|
|
205
|
+
return refs
|
|
206
|
+
if isinstance(refs, str) and refs:
|
|
207
|
+
return [refs]
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _issue(issue_id, path, message):
|
|
212
|
+
return {"id": issue_id, "path": path, "message": message}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from .errors import PackwrightValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_mechanism(path):
|
|
9
|
+
"""Load a character mechanism YAML document from disk.
|
|
10
|
+
|
|
11
|
+
A directory input resolves to `<directory>/mechanism.yaml`.
|
|
12
|
+
"""
|
|
13
|
+
input_path = Path(path)
|
|
14
|
+
mechanism_path = input_path / "mechanism.yaml" if input_path.is_dir() else input_path
|
|
15
|
+
try:
|
|
16
|
+
raw = mechanism_path.read_text(encoding="utf-8")
|
|
17
|
+
except OSError as exc:
|
|
18
|
+
raise PackwrightValidationError([f"cannot read mechanism file {mechanism_path}: {exc}"])
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
data = yaml.safe_load(raw)
|
|
22
|
+
except yaml.YAMLError as exc:
|
|
23
|
+
raise PackwrightValidationError([f"invalid YAML in {mechanism_path}: {exc}"])
|
|
24
|
+
|
|
25
|
+
if data is None:
|
|
26
|
+
data = {}
|
|
27
|
+
if not isinstance(data, dict):
|
|
28
|
+
raise PackwrightValidationError([f"mechanism root must be a mapping in {mechanism_path}"])
|
|
29
|
+
|
|
30
|
+
data = dict(data)
|
|
31
|
+
data["source"] = {
|
|
32
|
+
"path": str(mechanism_path),
|
|
33
|
+
"base_dir": str(mechanism_path.parent),
|
|
34
|
+
}
|
|
35
|
+
return data
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .naming import character_slug, reference_prefix, save_context_skill_path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def project_memory_file(mechanism, adapter, rel_path, text):
|
|
7
|
+
"""Project portable memory text into adapter-specific runtime wording."""
|
|
8
|
+
if rel_path == "memory/index.md":
|
|
9
|
+
return _project_memory_index(mechanism, adapter, text)
|
|
10
|
+
if rel_path == "memory/pinned.md":
|
|
11
|
+
return _project_pinned_memory(mechanism, adapter, text)
|
|
12
|
+
if rel_path == "memory/source-map.md":
|
|
13
|
+
return _project_source_map(mechanism, adapter, text)
|
|
14
|
+
return text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def adapter_entry_path(mechanism, adapter):
|
|
18
|
+
if adapter == "codex":
|
|
19
|
+
return "AGENTS.md"
|
|
20
|
+
if adapter == "claude-code":
|
|
21
|
+
return "CLAUDE.md"
|
|
22
|
+
if adapter == "cursor":
|
|
23
|
+
return f".cursor/rules/{character_slug(mechanism)}.mdc"
|
|
24
|
+
return "platform entry file"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _project_memory_index(mechanism, adapter, text):
|
|
28
|
+
entry = adapter_entry_path(mechanism, adapter)
|
|
29
|
+
text = re.sub(
|
|
30
|
+
r"- Stable identity, voice, and default work rules -> `[^`]+`(?: or equivalent platform entry file)?",
|
|
31
|
+
f"- Stable identity, voice, and default work rules -> `{entry}`",
|
|
32
|
+
text,
|
|
33
|
+
)
|
|
34
|
+
emotion_owner = (
|
|
35
|
+
"- Dynamic emotion state and compact emotion history -> `.emotion-engine/codex-state.json` when enabled"
|
|
36
|
+
if adapter == "codex"
|
|
37
|
+
else "- Dynamic emotion state and compact emotion history -> adapter-specific runtime state when installed; "
|
|
38
|
+
"`memory/emotion-state.json.example` is the portable reference shape"
|
|
39
|
+
)
|
|
40
|
+
text = re.sub(
|
|
41
|
+
r"- Dynamic emotion state and compact emotion history -> .+",
|
|
42
|
+
emotion_owner,
|
|
43
|
+
text,
|
|
44
|
+
)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _project_pinned_memory(mechanism, adapter, text):
|
|
49
|
+
entry = adapter_entry_path(mechanism, adapter)
|
|
50
|
+
text = re.sub(
|
|
51
|
+
r"Use `memory/index\.md` for routing, `[^`]+` for stable behavior,",
|
|
52
|
+
f"Use `memory/index.md` for routing, `{entry}` for stable behavior,",
|
|
53
|
+
text,
|
|
54
|
+
)
|
|
55
|
+
return re.sub(
|
|
56
|
+
r"Use `memory/index\.md` for routing, the platform entry file for stable behavior,",
|
|
57
|
+
f"Use `memory/index.md` for routing, `{entry}` for stable behavior,",
|
|
58
|
+
text,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _project_source_map(mechanism, adapter, text):
|
|
63
|
+
label = _adapter_label(adapter)
|
|
64
|
+
entry = adapter_entry_path(mechanism, adapter)
|
|
65
|
+
save_context = save_context_skill_path(mechanism, adapter)
|
|
66
|
+
save_context_name = "save-context rule" if adapter == "cursor" else "save-context skill"
|
|
67
|
+
update_policy = f"{reference_prefix(mechanism, adapter)}/emotion/update-policy.yaml"
|
|
68
|
+
|
|
69
|
+
text = re.sub(
|
|
70
|
+
r"- Current (Codex|Claude Code|Cursor) entry -> `[^`]+`",
|
|
71
|
+
f"- Current {label} entry -> `{entry}`",
|
|
72
|
+
text,
|
|
73
|
+
)
|
|
74
|
+
text = re.sub(
|
|
75
|
+
r"- Current save-context (skill|rule) -> `[^`]+`",
|
|
76
|
+
f"- Current {save_context_name} -> `{save_context}`",
|
|
77
|
+
text,
|
|
78
|
+
)
|
|
79
|
+
text = re.sub(
|
|
80
|
+
r"- Emotion update policy reference -> `[^`]+/emotion/update-policy\.yaml`",
|
|
81
|
+
f"- Emotion update policy reference -> `{update_policy}`",
|
|
82
|
+
text,
|
|
83
|
+
)
|
|
84
|
+
if adapter == "codex":
|
|
85
|
+
text = re.sub(
|
|
86
|
+
r"- Codex sidecar skill -> not installed in this [^\n]+ target",
|
|
87
|
+
"- Current Codex sidecar skill -> `.agents/skills/emotion-engine-codex/SKILL.md`",
|
|
88
|
+
text,
|
|
89
|
+
)
|
|
90
|
+
text = re.sub(
|
|
91
|
+
r"- Codex sidecar helper -> not installed in this [^\n]+ target",
|
|
92
|
+
"- Current Codex sidecar helper -> `.agents/skills/emotion-engine-codex/scripts/emotion_engine_utils.py`",
|
|
93
|
+
text,
|
|
94
|
+
)
|
|
95
|
+
text = re.sub(
|
|
96
|
+
r"- Project-local Emotion Engine state snapshot -> `\.emotion-engine/codex-state\.json` "
|
|
97
|
+
r"\(not active without an adapter sidecar\)",
|
|
98
|
+
"- Project-local Emotion Engine runtime state -> `.emotion-engine/codex-state.json`",
|
|
99
|
+
text,
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
text = re.sub(
|
|
103
|
+
r"- Current Codex sidecar skill -> `\.agents/skills/emotion-engine-codex/SKILL\.md`",
|
|
104
|
+
f"- Codex sidecar skill -> not installed in this {label} target",
|
|
105
|
+
text,
|
|
106
|
+
)
|
|
107
|
+
text = re.sub(
|
|
108
|
+
r"- Current Codex sidecar helper -> `\.agents/skills/emotion-engine-codex/scripts/emotion_engine_utils\.py`",
|
|
109
|
+
f"- Codex sidecar helper -> not installed in this {label} target",
|
|
110
|
+
text,
|
|
111
|
+
)
|
|
112
|
+
text = re.sub(
|
|
113
|
+
r"- Project-local Emotion Engine runtime state -> `\.emotion-engine/codex-state\.json`",
|
|
114
|
+
"- Project-local Emotion Engine state snapshot -> `.emotion-engine/codex-state.json` "
|
|
115
|
+
"(not active without an adapter sidecar)",
|
|
116
|
+
text,
|
|
117
|
+
)
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _adapter_label(adapter):
|
|
122
|
+
return {
|
|
123
|
+
"codex": "Codex",
|
|
124
|
+
"claude-code": "Claude Code",
|
|
125
|
+
"cursor": "Cursor",
|
|
126
|
+
}.get(adapter, adapter)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def slugify(value, default="character"):
|
|
9
|
+
text = str(value or "").strip().lower()
|
|
10
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
|
11
|
+
if text and not slug:
|
|
12
|
+
warnings.warn(
|
|
13
|
+
f"{value!r} cannot produce an ASCII slug; using {default!r}. "
|
|
14
|
+
"Provide an explicit --slug to avoid collisions.",
|
|
15
|
+
RuntimeWarning,
|
|
16
|
+
stacklevel=2,
|
|
17
|
+
)
|
|
18
|
+
return slug or default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_slug(value, default="character"):
|
|
22
|
+
return slugify(value, default=default)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_valid_slug(value):
|
|
26
|
+
return isinstance(value, str) and bool(SLUG_PATTERN.fullmatch(value))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def character_name(mechanism):
|
|
30
|
+
identity = mechanism.get("identity", {}) if isinstance(mechanism, dict) else {}
|
|
31
|
+
metadata = mechanism.get("metadata", {}) if isinstance(mechanism, dict) else {}
|
|
32
|
+
return identity.get("name") or metadata.get("name") or "Character"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def character_slug(mechanism):
|
|
36
|
+
identity = mechanism.get("identity", {}) if isinstance(mechanism, dict) else {}
|
|
37
|
+
if identity.get("slug"):
|
|
38
|
+
return normalize_slug(identity["slug"])
|
|
39
|
+
metadata = mechanism.get("metadata", {}) if isinstance(mechanism, dict) else {}
|
|
40
|
+
if metadata.get("slug"):
|
|
41
|
+
return normalize_slug(metadata["slug"])
|
|
42
|
+
name = identity.get("name")
|
|
43
|
+
if name:
|
|
44
|
+
return slugify(name)
|
|
45
|
+
return slugify(metadata.get("name"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def character_user_name(mechanism):
|
|
49
|
+
identity = mechanism.get("identity", {}) if isinstance(mechanism, dict) else {}
|
|
50
|
+
if identity.get("user_name"):
|
|
51
|
+
return identity["user_name"]
|
|
52
|
+
|
|
53
|
+
role = identity.get("role", "")
|
|
54
|
+
match = re.match(r"^([^\s'’]+)(?:'s|’s)\b", role)
|
|
55
|
+
if match:
|
|
56
|
+
return match.group(1)
|
|
57
|
+
match = re.match(r"^([^\s的]{1,64})的", role)
|
|
58
|
+
if match:
|
|
59
|
+
return match.group(1)
|
|
60
|
+
return "the user"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def character_voice_summary(mechanism):
|
|
64
|
+
identity = mechanism.get("identity", {}) if isinstance(mechanism, dict) else {}
|
|
65
|
+
return identity.get("voice_summary") or "Calm, direct, perceptive, and lightly warm."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def character_mission(mechanism):
|
|
69
|
+
identity = mechanism.get("identity", {}) if isinstance(mechanism, dict) else {}
|
|
70
|
+
if identity.get("mission"):
|
|
71
|
+
return identity["mission"]
|
|
72
|
+
name = character_name(mechanism)
|
|
73
|
+
user_name = character_user_name(mechanism)
|
|
74
|
+
return f"{name} helps {user_name} preserve intent, notice stale assumptions, and turn messy work into concrete next steps."
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_context_skill_path(mechanism, adapter):
|
|
78
|
+
from .adapter_layout import save_context_artifact
|
|
79
|
+
|
|
80
|
+
slug = character_slug(mechanism)
|
|
81
|
+
if adapter in {"codex", "claude-code", "cursor"}:
|
|
82
|
+
return save_context_artifact(adapter, slug)
|
|
83
|
+
return f"skills/{slug}-save-context/SKILL.md"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def reference_prefix(mechanism, adapter):
|
|
87
|
+
slug = character_slug(mechanism)
|
|
88
|
+
if adapter == "codex":
|
|
89
|
+
return f".codex/{slug}/references"
|
|
90
|
+
if adapter == "claude-code":
|
|
91
|
+
return f".claude/{slug}/references"
|
|
92
|
+
if adapter == "cursor":
|
|
93
|
+
return f".cursor/{slug}/references"
|
|
94
|
+
return f"{slug}/references"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def durable_memory_source(mechanism):
|
|
98
|
+
return f"{character_slug(mechanism)}_local_files"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import copy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import PackwrightValidationError
|
|
7
|
+
from .path_safety import resolve_source_path, validate_relative_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
METADATA_ROOT = ".packwright"
|
|
11
|
+
SPEC_PATH = f"{METADATA_ROOT}/spec.json"
|
|
12
|
+
LOCK_PATH = f"{METADATA_ROOT}/lock.json"
|
|
13
|
+
RECEIPT_PATH = f"{METADATA_ROOT}/checker-receipt.json"
|
|
14
|
+
METADATA_ARTIFACTS = (SPEC_PATH, LOCK_PATH, RECEIPT_PATH)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def embed_pack_metadata(pack, resolved, checker_receipt):
|
|
18
|
+
"""Return a pack with a portable canonical snapshot and build receipts."""
|
|
19
|
+
enriched = dict(pack)
|
|
20
|
+
manifest = json.loads(enriched["manifest.json"])
|
|
21
|
+
manifest["source_mechanism"] = SPEC_PATH
|
|
22
|
+
manifest["packwright"] = {
|
|
23
|
+
"schema": "packwright-pack-metadata/v1",
|
|
24
|
+
"spec": SPEC_PATH,
|
|
25
|
+
"lock": LOCK_PATH,
|
|
26
|
+
"checker_receipt": RECEIPT_PATH,
|
|
27
|
+
}
|
|
28
|
+
snapshot, source_files = _portable_snapshot(resolved)
|
|
29
|
+
artifacts = set(manifest.get("artifacts", []))
|
|
30
|
+
artifacts.update(METADATA_ARTIFACTS)
|
|
31
|
+
artifacts.update(source_files)
|
|
32
|
+
manifest["artifacts"] = sorted(artifacts)
|
|
33
|
+
enriched.update(source_files)
|
|
34
|
+
enriched[SPEC_PATH] = _json_text(snapshot)
|
|
35
|
+
enriched[RECEIPT_PATH] = _json_text(checker_receipt)
|
|
36
|
+
manifest_text = _json_text(manifest)
|
|
37
|
+
enriched["manifest.json"] = manifest_text
|
|
38
|
+
locked = {
|
|
39
|
+
path: hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
40
|
+
for path, content in sorted(enriched.items())
|
|
41
|
+
if path != LOCK_PATH
|
|
42
|
+
}
|
|
43
|
+
enriched[LOCK_PATH] = _json_text({
|
|
44
|
+
"schema": "packwright-lock/v1",
|
|
45
|
+
"artifacts": locked,
|
|
46
|
+
})
|
|
47
|
+
return enriched
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_embedded_spec(root):
|
|
51
|
+
"""Load the resolved snapshot with target-root-relative validation context."""
|
|
52
|
+
root = Path(root)
|
|
53
|
+
path = resolve_source_path(root, SPEC_PATH, "embedded mechanism spec")
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
57
|
+
raise PackwrightValidationError([f"invalid embedded mechanism spec {path}: {exc}"])
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise PackwrightValidationError([f"embedded mechanism spec must be a mapping: {path}"])
|
|
60
|
+
data["source"] = {
|
|
61
|
+
"path": str(path),
|
|
62
|
+
"base_dir": str(root / METADATA_ROOT / "source"),
|
|
63
|
+
"fallback_roots": [str(root)],
|
|
64
|
+
}
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _json_text(value):
|
|
69
|
+
return json.dumps(value, indent=2, sort_keys=True) + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _portable_snapshot(resolved):
|
|
73
|
+
snapshot = copy.deepcopy(resolved)
|
|
74
|
+
base = Path(resolved.get("source", {}).get("base_dir", "."))
|
|
75
|
+
files = {}
|
|
76
|
+
|
|
77
|
+
def visit(value):
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
for key, item in list(value.items()):
|
|
80
|
+
if key == "source":
|
|
81
|
+
continue
|
|
82
|
+
if key == "path" or key.endswith("_path"):
|
|
83
|
+
if isinstance(item, str):
|
|
84
|
+
relative = validate_relative_path(item, f"mechanism source path {key}")
|
|
85
|
+
candidate = base.resolve() / relative
|
|
86
|
+
if candidate.exists():
|
|
87
|
+
candidate = resolve_source_path(base, item, f"mechanism source path {key}")
|
|
88
|
+
# Keep spec paths stable because adapters also use them
|
|
89
|
+
# as destination names, while storing their source bytes
|
|
90
|
+
# under a private metadata root.
|
|
91
|
+
files[f"{METADATA_ROOT}/source/{relative.as_posix()}"] = candidate.read_text(encoding="utf-8")
|
|
92
|
+
else:
|
|
93
|
+
visit(item)
|
|
94
|
+
elif isinstance(value, list):
|
|
95
|
+
for item in value:
|
|
96
|
+
visit(item)
|
|
97
|
+
|
|
98
|
+
visit(snapshot)
|
|
99
|
+
snapshot["source"] = {"path": SPEC_PATH, "base_dir": "."}
|
|
100
|
+
return snapshot, files
|