@drdeeks/character-kit 1.0.1
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.
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/deploy/ack_monitor.py +140 -0
- package/deploy/ack_watchdog.py +110 -0
- package/deploy/agent-character-monitor.service +21 -0
- package/deploy/agent-character-watchdog.service +21 -0
- package/deploy/agent-enforcer-proof.service +18 -0
- package/deploy/agent-enforcer.service +53 -0
- package/deploy/deploy-ack-services.sh +47 -0
- package/deploy/deploy-agent-enforcer.sh +133 -0
- package/deploy/proof-self-respawn.sh +59 -0
- package/node/bin/ack.js +383 -0
- package/node/bin/aik.js +520 -0
- package/node/bin/install.js +409 -0
- package/node/enforcer/agent_enforcer_daemon.js +690 -0
- package/node/examples/.agent/constitution.yaml +38 -0
- package/node/examples/.agent/enforcer.yaml +33 -0
- package/node/examples/.agent/habits/affirm_character_each_action.yaml +9 -0
- package/node/examples/.agent/habits/audit_not_silent.yaml +9 -0
- package/node/examples/.agent/habits/binding_map_one_core.yaml +9 -0
- package/node/examples/.agent/habits/character_hash_visible.yaml +9 -0
- package/node/examples/.agent/habits/check_duplication_before_debug.yaml +9 -0
- package/node/examples/.agent/habits/complete-thoroughly.yaml +16 -0
- package/node/examples/.agent/habits/consistent-info-across-files.yaml +18 -0
- package/node/examples/.agent/habits/document-for-next-agent.yaml +15 -0
- package/node/examples/.agent/habits/documented_rollback.yaml +9 -0
- package/node/examples/.agent/habits/drift_signal_detection.yaml +9 -0
- package/node/examples/.agent/habits/due-diligence.yaml +16 -0
- package/node/examples/.agent/habits/enterprise-grade-modular.yaml +16 -0
- package/node/examples/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
- package/node/examples/.agent/habits/forever_one_idea.yaml +9 -0
- package/node/examples/.agent/habits/graceful_degradation.yaml +9 -0
- package/node/examples/.agent/habits/idempotent_operations.yaml +9 -0
- package/node/examples/.agent/habits/interactive-no-stalls.yaml +16 -0
- package/node/examples/.agent/habits/layered_not_rewritten.yaml +9 -0
- package/node/examples/.agent/habits/lossless_consolidation.yaml +9 -0
- package/node/examples/.agent/habits/no-credential-leak.yaml +41 -0
- package/node/examples/.agent/habits/no-deception.yaml +14 -0
- package/node/examples/.agent/habits/no_hold_narration.yaml +9 -0
- package/node/examples/.agent/habits/one_concern_per_file.yaml +9 -0
- package/node/examples/.agent/habits/optimized-robust.yaml +16 -0
- package/node/examples/.agent/habits/registered_plugin_not_string.yaml +9 -0
- package/node/examples/.agent/habits/rename_as_layer_op.yaml +9 -0
- package/node/examples/.agent/habits/resolve-root-cause.yaml +15 -0
- package/node/examples/.agent/habits/rigorous-commits-no-push.yaml +18 -0
- package/node/examples/.agent/habits/safe-deletion-via-trash.yaml +20 -0
- package/node/examples/.agent/habits/safe_file_permissions.yaml +9 -0
- package/node/examples/.agent/habits/self-healing-portability.yaml +14 -0
- package/node/examples/.agent/habits/shippable-pride.yaml +15 -0
- package/node/examples/.agent/habits/single_source_of_truth.yaml +9 -0
- package/node/examples/.agent/habits/test_of_forever.yaml +9 -0
- package/node/examples/.agent/habits/timeout_and_retry.yaml +9 -0
- package/node/examples/.agent/habits/track_defects_openly.yaml +9 -0
- package/node/examples/.agent/habits/validate-against-real-source.yaml +14 -0
- package/node/examples/.agent/habits/verify-against-docs.yaml +15 -0
- package/node/examples/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
- package/node/examples/.agent/habits/versioning-discipline.yaml +18 -0
- package/node/package.json +53 -0
- package/node/src/enforcer/client.js +149 -0
- package/node/src/hooks/character.js +286 -0
- package/node/src/index.js +24 -0
- package/node/src/knowledge/indexer.js +486 -0
- package/node/src/knowledge/semantic.js +154 -0
- package/node/src/memory/index.js +355 -0
- package/node/tests/character.test.js +80 -0
- package/node/tests/habit-create.test.js +67 -0
- package/node/tests/habits-default.test.js +68 -0
- package/node/tests/install-chain.test.js +19 -0
- package/node/tests/install.test.js +104 -0
- package/package.json +65 -0
- package/python/agent_character_kit/__init__.py +388 -0
- package/python/agent_character_kit/__main__.py +333 -0
- package/python/agent_character_kit/_yaml.py +33 -0
- package/python/agent_character_kit/enforcer.py +379 -0
- package/python/agent_character_kit/memory.py +257 -0
- package/python/agent_character_kit/semantic.py +126 -0
- package/python/example_workspace/.agent/habits/affirm_character_each_action.yaml +9 -0
- package/python/example_workspace/.agent/habits/audit_not_silent.yaml +9 -0
- package/python/example_workspace/.agent/habits/binding_map_one_core.yaml +9 -0
- package/python/example_workspace/.agent/habits/character_hash_visible.yaml +9 -0
- package/python/example_workspace/.agent/habits/check_duplication_before_debug.yaml +9 -0
- package/python/example_workspace/.agent/habits/complete-thoroughly.yaml +16 -0
- package/python/example_workspace/.agent/habits/consistent-info-across-files.yaml +18 -0
- package/python/example_workspace/.agent/habits/document-for-next-agent.yaml +15 -0
- package/python/example_workspace/.agent/habits/documented_rollback.yaml +9 -0
- package/python/example_workspace/.agent/habits/drift_signal_detection.yaml +9 -0
- package/python/example_workspace/.agent/habits/due-diligence.yaml +16 -0
- package/python/example_workspace/.agent/habits/enterprise-grade-modular.yaml +16 -0
- package/python/example_workspace/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
- package/python/example_workspace/.agent/habits/forever_one_idea.yaml +9 -0
- package/python/example_workspace/.agent/habits/graceful_degradation.yaml +9 -0
- package/python/example_workspace/.agent/habits/idempotent_operations.yaml +9 -0
- package/python/example_workspace/.agent/habits/interactive-no-stalls.yaml +16 -0
- package/python/example_workspace/.agent/habits/layered_not_rewritten.yaml +9 -0
- package/python/example_workspace/.agent/habits/lossless_consolidation.yaml +9 -0
- package/python/example_workspace/.agent/habits/no-credential-leak.yaml +41 -0
- package/python/example_workspace/.agent/habits/no-deception.yaml +14 -0
- package/python/example_workspace/.agent/habits/no_hold_narration.yaml +9 -0
- package/python/example_workspace/.agent/habits/one_concern_per_file.yaml +9 -0
- package/python/example_workspace/.agent/habits/optimized-robust.yaml +16 -0
- package/python/example_workspace/.agent/habits/registered_plugin_not_string.yaml +9 -0
- package/python/example_workspace/.agent/habits/rename_as_layer_op.yaml +9 -0
- package/python/example_workspace/.agent/habits/resolve-root-cause.yaml +15 -0
- package/python/example_workspace/.agent/habits/rigorous-commits-no-push.yaml +18 -0
- package/python/example_workspace/.agent/habits/safe-deletion-via-trash.yaml +20 -0
- package/python/example_workspace/.agent/habits/safe_file_permissions.yaml +9 -0
- package/python/example_workspace/.agent/habits/self-healing-portability.yaml +14 -0
- package/python/example_workspace/.agent/habits/shippable-pride.yaml +15 -0
- package/python/example_workspace/.agent/habits/single_source_of_truth.yaml +9 -0
- package/python/example_workspace/.agent/habits/test_of_forever.yaml +9 -0
- package/python/example_workspace/.agent/habits/timeout_and_retry.yaml +9 -0
- package/python/example_workspace/.agent/habits/track_defects_openly.yaml +9 -0
- package/python/example_workspace/.agent/habits/validate-against-real-source.yaml +14 -0
- package/python/example_workspace/.agent/habits/verify-against-docs.yaml +15 -0
- package/python/example_workspace/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
- package/python/example_workspace/.agent/habits/versioning-discipline.yaml +18 -0
- package/python/hermes_plugin/README.md +63 -0
- package/python/hermes_plugin/__init__.py +367 -0
- package/python/hermes_plugin/config.yaml +20 -0
- package/python/hermes_plugin/plugin.yaml +7 -0
- package/python/hermes_plugin/test_plugin.py +166 -0
- package/python/pyproject.toml +19 -0
- package/python/tests/python_parity_test.py +84 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enforcer client — RPC to the character enforcer daemon over a Unix socket.
|
|
3
|
+
|
|
4
|
+
Mirrors the Node `src/enforcer/client.js`. The agent uses this to validate every
|
|
5
|
+
tool call. The daemon runs as a separate, tamper-proof process; this client only
|
|
6
|
+
talks to it. All paths self-resolve.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ._yaml import load_yaml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _socket_path():
|
|
19
|
+
# Self-resolving: ENFORCER_SOCKET wins; else the socket lives UNDER the
|
|
20
|
+
# user-chosen workspace (AGENT_WORKSPACE/.agent/enforcer.sock) so the kit
|
|
21
|
+
# runs portably on any host (no /run, no harness-specific path). The Node
|
|
22
|
+
# daemon uses the same default — they stay in sync without hardcoding.
|
|
23
|
+
if os.environ.get("ENFORCER_SOCKET"):
|
|
24
|
+
return os.environ["ENFORCER_SOCKET"]
|
|
25
|
+
ws = os.environ.get("AGENT_WORKSPACE")
|
|
26
|
+
if ws:
|
|
27
|
+
return os.path.join(ws, ".agent", "enforcer.sock")
|
|
28
|
+
home = os.environ.get("HOME", "/root")
|
|
29
|
+
return os.path.join(home, ".agent-character-kit", "workspace", ".agent", "enforcer.sock")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_config():
|
|
33
|
+
"""Self-resolving paths, mirroring the Node daemon.
|
|
34
|
+
|
|
35
|
+
Resolved fresh each call so env overrides (and tests) take effect and the
|
|
36
|
+
package stays platform/path-agnostic.
|
|
37
|
+
"""
|
|
38
|
+
home = Path(os.environ.get("HOME", "/root"))
|
|
39
|
+
workspace = Path(os.environ.get("AGENT_WORKSPACE", home / ".agent-character-kit" / "workspace"))
|
|
40
|
+
socket_path = _socket_path()
|
|
41
|
+
agent_dir = workspace / ".agent"
|
|
42
|
+
constitution = agent_dir / "constitution.yaml"
|
|
43
|
+
habits_dir = agent_dir / "habits"
|
|
44
|
+
policy_file = Path(os.environ.get("ENFORCER_POLICY", agent_dir / "enforcer.yaml"))
|
|
45
|
+
return {
|
|
46
|
+
"home": home,
|
|
47
|
+
"workspace": workspace,
|
|
48
|
+
"socket": socket_path,
|
|
49
|
+
"agent_dir": agent_dir,
|
|
50
|
+
"constitution": constitution,
|
|
51
|
+
"habits_dir": habits_dir,
|
|
52
|
+
"policy_file": policy_file,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Enforcer:
|
|
57
|
+
"""Policy-evaluation engine — mirrors the Node `Enforcer` class in the daemon.
|
|
58
|
+
|
|
59
|
+
This is the same enforcement logic the daemon runs, exposed for in-process use
|
|
60
|
+
(tests, or Python-first setups). The daemon wraps this behind an RPC socket;
|
|
61
|
+
`EnforcerClient` is the agent-side client that talks to that socket.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self.cfg = _resolve_config()
|
|
66
|
+
self.constitution = load_yaml(self.cfg["constitution"])
|
|
67
|
+
self.habits = self._load_habits()
|
|
68
|
+
self.policy = load_yaml(self.cfg["policy_file"])
|
|
69
|
+
self.character_hash = self._hash({
|
|
70
|
+
"c": self.constitution, "h": self.habits, "p": self.policy,
|
|
71
|
+
})
|
|
72
|
+
self.started_at = self._now()
|
|
73
|
+
self.last_heartbeat = self._now()
|
|
74
|
+
|
|
75
|
+
def _now(self):
|
|
76
|
+
from datetime import datetime, timezone
|
|
77
|
+
return datetime.now(timezone.utc).isoformat()
|
|
78
|
+
|
|
79
|
+
def _load_habits(self):
|
|
80
|
+
habits = []
|
|
81
|
+
try:
|
|
82
|
+
for f in sorted(self.cfg["habits_dir"].iterdir()):
|
|
83
|
+
if f.suffix in (".yaml", ".yml"):
|
|
84
|
+
habits.append(load_yaml(f))
|
|
85
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
86
|
+
pass
|
|
87
|
+
return habits
|
|
88
|
+
|
|
89
|
+
def _hash(self, obj):
|
|
90
|
+
s = json.dumps(obj, default=str, sort_keys=True)
|
|
91
|
+
h = 0
|
|
92
|
+
for ch in s:
|
|
93
|
+
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
|
|
94
|
+
return format(h, "x")
|
|
95
|
+
|
|
96
|
+
def reload(self):
|
|
97
|
+
self.constitution = load_yaml(self.cfg["constitution"])
|
|
98
|
+
self.habits = self._load_habits()
|
|
99
|
+
self.policy = load_yaml(self.cfg["policy_file"])
|
|
100
|
+
self.character_hash = self._hash({
|
|
101
|
+
"c": self.constitution, "h": self.habits, "p": self.policy,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
# ─── Core enforcement ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def execute_tool(self, tool, params=None):
|
|
107
|
+
params = params or {}
|
|
108
|
+
command = self._extract_command(tool, params)
|
|
109
|
+
|
|
110
|
+
# 1. Explicit deny patterns (constitution hard_constraints + policy.deny)
|
|
111
|
+
deny_patterns = list(self.constitution.get("hard_constraints", [])) + \
|
|
112
|
+
list(self.policy.get("deny", []))
|
|
113
|
+
for p in deny_patterns:
|
|
114
|
+
if self._matches(p, tool, command):
|
|
115
|
+
result = {
|
|
116
|
+
"denied": True,
|
|
117
|
+
"reason": f"Violates hard constraint: {p}",
|
|
118
|
+
"reflection": "This isn't a rule to work around — it's who we are. "
|
|
119
|
+
"A constraint exists because the cost of the failure is worse than the convenience.",
|
|
120
|
+
}
|
|
121
|
+
self._audit(tool, command, result)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
# 2. Allow-list policy: if policy.allow is set, ONLY listed tools/commands pass.
|
|
125
|
+
allow = self.policy.get("allow")
|
|
126
|
+
if isinstance(allow, list) and allow:
|
|
127
|
+
ok = any(self._matches(p, tool, command, allow_mode=True) for p in allow)
|
|
128
|
+
if not ok:
|
|
129
|
+
result = {
|
|
130
|
+
"denied": True,
|
|
131
|
+
"reason": f"Tool not on allow-list: {command or tool}",
|
|
132
|
+
"reflection": "Unlisted tools are denied by default. Add it to enforcer.yaml allow-list "
|
|
133
|
+
"if it is genuinely needed — but raising the bar is the point.",
|
|
134
|
+
}
|
|
135
|
+
self._audit(tool, command, result)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
# 3. Habit checks (each habit may block) — internalized, not optional.
|
|
139
|
+
for habit in self.habits:
|
|
140
|
+
block = self._eval_habit(habit, tool, command)
|
|
141
|
+
if block:
|
|
142
|
+
result = {
|
|
143
|
+
"denied": True,
|
|
144
|
+
"reason": block,
|
|
145
|
+
"reflection": "A compiled habit blocked this. Habits are internalized, not optional.",
|
|
146
|
+
}
|
|
147
|
+
self._audit(tool, command, result)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
# 4. Allowed — but still recorded, so every action carries the character trail.
|
|
151
|
+
# Re-assert the self-audit reminders (FOREVER-SYSTEM.md §6): character is
|
|
152
|
+
# exercised on EVERY action, not checked once.
|
|
153
|
+
reminders = self._collect_reminders()
|
|
154
|
+
result = {"denied": False}
|
|
155
|
+
if reminders:
|
|
156
|
+
result["reminders"] = reminders
|
|
157
|
+
self._audit(tool, command, result)
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
def audit_path(self):
|
|
161
|
+
return self.cfg["agent_dir"] / "logs" / "enforcer-audit.jsonl"
|
|
162
|
+
|
|
163
|
+
def _audit(self, tool, command, result):
|
|
164
|
+
from pathlib import Path as _P
|
|
165
|
+
try:
|
|
166
|
+
log = self.audit_path()
|
|
167
|
+
log.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
entry = {
|
|
169
|
+
"ts": self._now(),
|
|
170
|
+
"character_hash": self.character_hash,
|
|
171
|
+
"tool": tool,
|
|
172
|
+
"command": (command or "")[:500],
|
|
173
|
+
"decision": "deny" if result.get("denied") else "allow",
|
|
174
|
+
"reason": result.get("reason") or None,
|
|
175
|
+
}
|
|
176
|
+
with log.open("a", encoding="utf-8") as f:
|
|
177
|
+
f.write(json.dumps(entry) + "\n")
|
|
178
|
+
except Exception:
|
|
179
|
+
pass # audit must never break enforcement
|
|
180
|
+
|
|
181
|
+
def _extract_command(self, tool, params):
|
|
182
|
+
if isinstance(params, str):
|
|
183
|
+
return params
|
|
184
|
+
if isinstance(params, dict):
|
|
185
|
+
for key in ("command", "cmd", "code"):
|
|
186
|
+
if isinstance(params.get(key), str):
|
|
187
|
+
return params[key]
|
|
188
|
+
return str(tool or "")
|
|
189
|
+
|
|
190
|
+
def _matches(self, pattern, tool, command, allow_mode=False):
|
|
191
|
+
p = str(pattern or "").strip()
|
|
192
|
+
if not p:
|
|
193
|
+
return False
|
|
194
|
+
hay = f"{tool} {command}".lower()
|
|
195
|
+
|
|
196
|
+
if not allow_mode:
|
|
197
|
+
return p.lower() in hay
|
|
198
|
+
|
|
199
|
+
# Glob-ish for allow-list: "git *", "npm test", "ls"
|
|
200
|
+
import re
|
|
201
|
+
rx = re.compile(
|
|
202
|
+
"^" + re.escape(p.lower()).replace(r"\*", ".*") + "$"
|
|
203
|
+
)
|
|
204
|
+
candidates = [
|
|
205
|
+
str(tool).lower(),
|
|
206
|
+
str(command).lower(),
|
|
207
|
+
(str(command).lower().split()[0] if command else ""),
|
|
208
|
+
]
|
|
209
|
+
return any(rx.match(c) for c in candidates)
|
|
210
|
+
|
|
211
|
+
def _eval_habit(self, habit, tool, command):
|
|
212
|
+
if not habit or not habit.get("enforcement"):
|
|
213
|
+
return None
|
|
214
|
+
level = habit["enforcement"].get("level")
|
|
215
|
+
if level != "hard":
|
|
216
|
+
return None # reminder habits never block; handled in execute_tool
|
|
217
|
+
checks = (habit.get("behavior", {}) or {}).get("steps", []) or []
|
|
218
|
+
for step in checks:
|
|
219
|
+
check = step.get("check", "")
|
|
220
|
+
if check == "executable_and_present":
|
|
221
|
+
bin_name = step.get("binary") or (step.get("name") or "").replace("validate_", "")
|
|
222
|
+
if bin_name and not self._has_binary(bin_name):
|
|
223
|
+
return f"Required tool missing: {bin_name}"
|
|
224
|
+
if check == "block_command_pattern" and step.get("pattern"):
|
|
225
|
+
if self._matches(step["pattern"], tool, command):
|
|
226
|
+
return f"Blocked by habit {habit.get('name')}: {step['pattern']}"
|
|
227
|
+
if check == "block_secret_leak":
|
|
228
|
+
if self._leaks_secret(tool, command, step):
|
|
229
|
+
return (
|
|
230
|
+
f"Blocked by habit {habit.get('name')}: probable credential "
|
|
231
|
+
f"leak detected in tool call. A guard that fails open on "
|
|
232
|
+
f"secrets is no guard."
|
|
233
|
+
)
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
def _leaks_secret(self, tool, command, step) -> bool:
|
|
237
|
+
"""Fail-closed secret scan. Returns True if the call carries what looks
|
|
238
|
+
like a real credential value (not merely mentioning the word)."""
|
|
239
|
+
hay = f"{tool} {command}"
|
|
240
|
+
patterns = step.get("patterns") or []
|
|
241
|
+
for pat in patterns:
|
|
242
|
+
idx = hay.find(pat)
|
|
243
|
+
if idx == -1:
|
|
244
|
+
continue
|
|
245
|
+
# Known secret prefixes (sk-, AKIA, xoxb-, ghp_, ...) are themselves
|
|
246
|
+
# values — their mere presence is the leak. Fail closed on these.
|
|
247
|
+
if pat in ("sk-", "sk_", "AIza", "xoxb-", "xoxp-", "AKIA",
|
|
248
|
+
"ghp_", "gho_", "glpat-", "-----BEGIN PRIVATE KEY-----"):
|
|
249
|
+
return True
|
|
250
|
+
# key= / key: forms — block if a value follows the assignment.
|
|
251
|
+
# e.g. "api_key=sk-..." or "token: abc123" or trailing "secret="
|
|
252
|
+
tail = hay[idx + len(pat):]
|
|
253
|
+
if pat in ("api_key=", "apikey=", "password=", "secret=",
|
|
254
|
+
"token=", "client_secret="):
|
|
255
|
+
# value present if tail is non-empty and not just whitespace/punct
|
|
256
|
+
if tail.strip() and not tail.lstrip().startswith(("'", '"', "#")):
|
|
257
|
+
return True
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
def _collect_reminders(self):
|
|
261
|
+
"""Gather reminder-level habit questions to re-assert on every allowed
|
|
262
|
+
action (FOREVER-SYSTEM.md §6 — character continuously reminded)."""
|
|
263
|
+
out = []
|
|
264
|
+
for habit in self.habits:
|
|
265
|
+
if habit.get("enforcement", {}).get("level") == "reminder":
|
|
266
|
+
rem = (habit.get("behavior", {}) or {}).get("reminder") or []
|
|
267
|
+
out.extend(rem)
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
def _has_binary(self, name):
|
|
271
|
+
import shutil
|
|
272
|
+
return shutil.which(name) is not None
|
|
273
|
+
|
|
274
|
+
def validate_workspace(self):
|
|
275
|
+
violations = []
|
|
276
|
+
if not self.cfg["constitution"].exists():
|
|
277
|
+
violations.append("constitution.yaml missing")
|
|
278
|
+
if not self.cfg["policy_file"].exists():
|
|
279
|
+
violations.append("enforcer.yaml missing (using open policy)")
|
|
280
|
+
return violations
|
|
281
|
+
|
|
282
|
+
def heartbeat(self):
|
|
283
|
+
self.last_heartbeat = self._now()
|
|
284
|
+
return {
|
|
285
|
+
"status": "ok",
|
|
286
|
+
"character_hash": self.character_hash,
|
|
287
|
+
"violations": self.validate_workspace(),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class EnforcerClient:
|
|
292
|
+
"""Thin RPC client to the enforcer daemon.
|
|
293
|
+
|
|
294
|
+
Supports both transports, matching the daemon:
|
|
295
|
+
- "tcp://host:port" -> TCP (Windows / cross-host)
|
|
296
|
+
- "/path/to/main.sock" -> Unix domain socket (POSIX)
|
|
297
|
+
Fail-closed: any failure to reach/parse the daemon -> {"error": ...},
|
|
298
|
+
which the callers turn into a BLOCK.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(self, socket_path=None):
|
|
302
|
+
raw = str(socket_path or _socket_path())
|
|
303
|
+
self.raw = raw
|
|
304
|
+
self.is_tcp = raw.startswith("tcp://")
|
|
305
|
+
if self.is_tcp:
|
|
306
|
+
from urllib.parse import urlparse
|
|
307
|
+
u = urlparse(raw)
|
|
308
|
+
self.host = u.hostname or "127.0.0.1"
|
|
309
|
+
self.port = int(u.port or 8753)
|
|
310
|
+
else:
|
|
311
|
+
self.unix_path = raw
|
|
312
|
+
|
|
313
|
+
async def call(self, method, params=None):
|
|
314
|
+
try:
|
|
315
|
+
if self.is_tcp:
|
|
316
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
317
|
+
s.settimeout(5)
|
|
318
|
+
s.connect((self.host, self.port))
|
|
319
|
+
s.sendall((json.dumps({"method": method, "params": params or {}}) + "\n").encode())
|
|
320
|
+
buf = b""
|
|
321
|
+
while b"\n" not in buf:
|
|
322
|
+
chunk = s.recv(65536)
|
|
323
|
+
if not chunk:
|
|
324
|
+
break
|
|
325
|
+
buf += chunk
|
|
326
|
+
else:
|
|
327
|
+
if not os.path.exists(self.unix_path):
|
|
328
|
+
return {"error": "enforcer socket not found"}
|
|
329
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
330
|
+
s.settimeout(5)
|
|
331
|
+
s.connect(self.unix_path)
|
|
332
|
+
s.sendall((json.dumps({"method": method, "params": params or {}}) + "\n").encode())
|
|
333
|
+
buf = b""
|
|
334
|
+
while b"\n" not in buf:
|
|
335
|
+
chunk = s.recv(65536)
|
|
336
|
+
if not chunk:
|
|
337
|
+
break
|
|
338
|
+
buf += chunk
|
|
339
|
+
line = buf.split(b"\n", 1)[0].decode()
|
|
340
|
+
resp = json.loads(line)
|
|
341
|
+
if not isinstance(resp, dict):
|
|
342
|
+
return {"error": "malformed enforcer response"}
|
|
343
|
+
return resp
|
|
344
|
+
except Exception:
|
|
345
|
+
# Any failure to reach/parse the daemon is treated as unreachable.
|
|
346
|
+
return {"error": "enforcer socket unreachable"}
|
|
347
|
+
|
|
348
|
+
async def validate_tool(self, tool, params, character_hash="unknown"):
|
|
349
|
+
# Flat contract, mirroring the node EnforcerClient fix: the daemon's
|
|
350
|
+
# executeTool(tool, params) reads params.command. Do NOT nest params
|
|
351
|
+
# inside params — that made _extractCommand resolve the command to the
|
|
352
|
+
# tool name, so every call passed (deny never fired).
|
|
353
|
+
command = ""
|
|
354
|
+
if isinstance(params, dict):
|
|
355
|
+
for k in ("command", "cmd", "code"):
|
|
356
|
+
if isinstance(params.get(k), str):
|
|
357
|
+
command = params[k]
|
|
358
|
+
break
|
|
359
|
+
resp = await self.call("execute_tool", {"tool": tool, "command": command,
|
|
360
|
+
"character_hash": character_hash})
|
|
361
|
+
if not isinstance(resp, dict) or resp.get("error"):
|
|
362
|
+
# Fail-closed: if the enforcer can't be reached/parsed, block.
|
|
363
|
+
return {"allowed": False, "error": True,
|
|
364
|
+
"reason": "Enforcer unavailable: character cannot be verified, "
|
|
365
|
+
"so the action is blocked. A guard that fails open is no guard."}
|
|
366
|
+
if resp.get("denied"):
|
|
367
|
+
return {"allowed": False, "reason": resp.get("reason", "Denied by enforcer"),
|
|
368
|
+
"reflection": resp.get("reflection", "")}
|
|
369
|
+
return {"allowed": True}
|
|
370
|
+
|
|
371
|
+
async def heartbeat(self, status="ok"):
|
|
372
|
+
return await self.call("heartbeat", {"status": status})
|
|
373
|
+
|
|
374
|
+
async def validate_workspace(self):
|
|
375
|
+
return await self.call("validate_workspace")
|
|
376
|
+
|
|
377
|
+
async def get_habit(self, name):
|
|
378
|
+
"""On-demand proof layer: pull a habit's full assert/evidence/logic."""
|
|
379
|
+
return await self.call("get_habit", {"name": name})
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Memory — three-layer continuity (daily / weekly / long-term) + knowledge graph.
|
|
3
|
+
|
|
4
|
+
Mirrors the Node `src/memory/index.js` module so both packages are at parity.
|
|
5
|
+
Each class is fully independent: use only what you need. No component is required
|
|
6
|
+
by any other — the hook/identity layer works with none of this present.
|
|
7
|
+
|
|
8
|
+
Files (all YAML, self-resolving under the workspace):
|
|
9
|
+
memory/daily/YYYY-MM-DD.yaml
|
|
10
|
+
memory/weekly/week-YYYY-MM-DD.yaml
|
|
11
|
+
memory/long-term.yaml
|
|
12
|
+
knowledge/entities/<name>.yaml
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _now_iso():
|
|
21
|
+
return datetime.now(timezone.utc).isoformat()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _utc_date():
|
|
25
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─── Daily notes ──────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class DailyNotes:
|
|
31
|
+
def __init__(self, workspace: str):
|
|
32
|
+
self.workspace = Path(workspace)
|
|
33
|
+
self.daily_dir = self.workspace / "memory" / "daily"
|
|
34
|
+
|
|
35
|
+
def init(self):
|
|
36
|
+
self.daily_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
def _today_file(self):
|
|
39
|
+
return self.daily_dir / f"{_utc_date()}.yaml"
|
|
40
|
+
|
|
41
|
+
def log(self, entry, tags=None, category="general"):
|
|
42
|
+
from ._yaml import load_yaml, dump_yaml # local import keeps deps lazy
|
|
43
|
+
self.init()
|
|
44
|
+
fp = self._today_file()
|
|
45
|
+
data = load_yaml(fp) or {"date": _utc_date(), "created_at": _now_iso(), "entries": []}
|
|
46
|
+
data.setdefault("entries", []).append({
|
|
47
|
+
"timestamp": _now_iso(),
|
|
48
|
+
"content": entry,
|
|
49
|
+
"tags": tags or [],
|
|
50
|
+
"category": category,
|
|
51
|
+
})
|
|
52
|
+
data["updated_at"] = _now_iso()
|
|
53
|
+
fp.write_text(dump_yaml(data))
|
|
54
|
+
return {"status": "logged", "file": str(fp)}
|
|
55
|
+
|
|
56
|
+
def get_today(self):
|
|
57
|
+
from ._yaml import load_yaml
|
|
58
|
+
try:
|
|
59
|
+
return load_yaml(self._today_file()) or {}
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return {"date": _utc_date(), "entries": []}
|
|
62
|
+
|
|
63
|
+
def list_notes(self):
|
|
64
|
+
from ._yaml import load_yaml
|
|
65
|
+
notes = []
|
|
66
|
+
try:
|
|
67
|
+
for f in sorted(self.daily_dir.glob("*.yaml"), reverse=True):
|
|
68
|
+
data = load_yaml(f) or {}
|
|
69
|
+
notes.append({"date": data.get("date", f.stem), "file": str(f),
|
|
70
|
+
"entries": len(data.get("entries", []))})
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
pass
|
|
73
|
+
return notes
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ─── Weekly digest ────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class WeeklyDigest:
|
|
79
|
+
def __init__(self, workspace: str):
|
|
80
|
+
self.workspace = Path(workspace)
|
|
81
|
+
self.weekly_dir = self.workspace / "memory" / "weekly"
|
|
82
|
+
|
|
83
|
+
def init(self):
|
|
84
|
+
self.weekly_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
def create(self, week_start, summary, patterns=None, decisions=None):
|
|
87
|
+
from ._yaml import dump_yaml
|
|
88
|
+
self.init()
|
|
89
|
+
fp = self.weekly_dir / f"week-{week_start}.yaml"
|
|
90
|
+
data = {"week_start": week_start, "created_at": _now_iso(), "summary": summary,
|
|
91
|
+
"patterns": patterns or [], "decisions": decisions or []}
|
|
92
|
+
fp.write_text(dump_yaml(data))
|
|
93
|
+
return {"status": "created", "file": str(fp)}
|
|
94
|
+
|
|
95
|
+
def get(self, week_start):
|
|
96
|
+
from ._yaml import load_yaml
|
|
97
|
+
try:
|
|
98
|
+
return load_yaml(self.weekly_dir / f"week-{week_start}.yaml") or {}
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
return {}
|
|
101
|
+
|
|
102
|
+
def list_digests(self):
|
|
103
|
+
from ._yaml import load_yaml
|
|
104
|
+
out = []
|
|
105
|
+
try:
|
|
106
|
+
for f in sorted(self.weekly_dir.glob("week-*.yaml"), reverse=True):
|
|
107
|
+
data = load_yaml(f) or {}
|
|
108
|
+
out.append({"weekStart": data.get("week_start", f.stem), "file": str(f),
|
|
109
|
+
"patterns": len(data.get("patterns", []))})
|
|
110
|
+
except FileNotFoundError:
|
|
111
|
+
pass
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ─── Long-term memory ─────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
class LongTermMemory:
|
|
118
|
+
def __init__(self, workspace: str):
|
|
119
|
+
self.workspace = Path(workspace)
|
|
120
|
+
self.file_path = self.workspace / "memory" / "long-term.yaml"
|
|
121
|
+
|
|
122
|
+
def init(self):
|
|
123
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
|
|
125
|
+
def _load(self):
|
|
126
|
+
from ._yaml import load_yaml
|
|
127
|
+
try:
|
|
128
|
+
return load_yaml(self.file_path) or {"lessons": [], "patterns": [], "decisions": []}
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
return {"lessons": [], "patterns": [], "decisions": []}
|
|
131
|
+
|
|
132
|
+
def _save(self, data):
|
|
133
|
+
from ._yaml import dump_yaml
|
|
134
|
+
data["updated_at"] = _now_iso()
|
|
135
|
+
self.file_path.write_text(dump_yaml(data))
|
|
136
|
+
|
|
137
|
+
def add_lesson(self, title, content, tags=None, category="general"):
|
|
138
|
+
data = self._load()
|
|
139
|
+
data.setdefault("lessons", []).append({
|
|
140
|
+
"title": title, "content": content, "tags": tags or [],
|
|
141
|
+
"category": category, "added_at": _now_iso()})
|
|
142
|
+
self._save(data)
|
|
143
|
+
return {"status": "added", "total": len(data["lessons"])}
|
|
144
|
+
|
|
145
|
+
def add_pattern(self, name, description, examples=None):
|
|
146
|
+
data = self._load()
|
|
147
|
+
data.setdefault("patterns", []).append({
|
|
148
|
+
"name": name, "description": description, "examples": examples or [],
|
|
149
|
+
"added_at": _now_iso()})
|
|
150
|
+
self._save(data)
|
|
151
|
+
return {"status": "added", "total": len(data["patterns"])}
|
|
152
|
+
|
|
153
|
+
def add_decision(self, title, context, decision, rationale):
|
|
154
|
+
data = self._load()
|
|
155
|
+
data.setdefault("decisions", []).append({
|
|
156
|
+
"title": title, "context": context, "decision": decision,
|
|
157
|
+
"rationale": rationale, "made_at": _now_iso()})
|
|
158
|
+
self._save(data)
|
|
159
|
+
return {"status": "added", "total": len(data["decisions"])}
|
|
160
|
+
|
|
161
|
+
def search(self, query):
|
|
162
|
+
q = query.lower()
|
|
163
|
+
data = self._load()
|
|
164
|
+
out = []
|
|
165
|
+
for kind in ("lessons", "patterns", "decisions"):
|
|
166
|
+
for item in data.get(kind, []):
|
|
167
|
+
blob = " ".join(str(v) for v in item.values()).lower()
|
|
168
|
+
if q in blob:
|
|
169
|
+
out.append({"type": kind, **item})
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
def get_all(self):
|
|
173
|
+
return self._load()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ─── Knowledge graph ──────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
class KnowledgeGraph:
|
|
179
|
+
def __init__(self, workspace: str):
|
|
180
|
+
self.workspace = Path(workspace)
|
|
181
|
+
self.entities_dir = self.workspace / "knowledge" / "entities"
|
|
182
|
+
|
|
183
|
+
def init(self):
|
|
184
|
+
self.entities_dir.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
|
|
186
|
+
def _entity_file(self, name):
|
|
187
|
+
safe = name.lower().replace(" ", "-").replace("/", "-")
|
|
188
|
+
return self.entities_dir / f"{safe}.yaml"
|
|
189
|
+
|
|
190
|
+
def add_entity(self, name, etype, facts=None, tags=None):
|
|
191
|
+
from ._yaml import load_yaml, dump_yaml
|
|
192
|
+
self.init()
|
|
193
|
+
fp = self._entity_file(name)
|
|
194
|
+
existing = load_yaml(fp) or {}
|
|
195
|
+
data = {"name": name, "type": etype, "tags": tags or [], "facts": facts or {},
|
|
196
|
+
"created_at": existing.get("created_at", _now_iso()), "updated_at": _now_iso()}
|
|
197
|
+
fp.write_text(dump_yaml(data))
|
|
198
|
+
return {"status": "added", "file": str(fp)}
|
|
199
|
+
|
|
200
|
+
def get_entity(self, name):
|
|
201
|
+
from ._yaml import load_yaml
|
|
202
|
+
try:
|
|
203
|
+
return load_yaml(self._entity_file(name)) or {}
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
return {}
|
|
206
|
+
|
|
207
|
+
def search_entities(self, query):
|
|
208
|
+
from ._yaml import load_yaml
|
|
209
|
+
out = []
|
|
210
|
+
try:
|
|
211
|
+
for f in self.entities_dir.glob("*.yaml"):
|
|
212
|
+
data = load_yaml(f) or {}
|
|
213
|
+
if query.lower() in str(data).lower():
|
|
214
|
+
out.append(data)
|
|
215
|
+
except FileNotFoundError:
|
|
216
|
+
pass
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
def list_entities(self, etype=None):
|
|
220
|
+
from ._yaml import load_yaml
|
|
221
|
+
out = []
|
|
222
|
+
try:
|
|
223
|
+
for f in self.entities_dir.glob("*.yaml"):
|
|
224
|
+
data = load_yaml(f) or {}
|
|
225
|
+
if etype and data.get("type") != etype:
|
|
226
|
+
continue
|
|
227
|
+
out.append({"name": data.get("name"), "type": data.get("type"), "file": str(f)})
|
|
228
|
+
except FileNotFoundError:
|
|
229
|
+
pass
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ─── Aggregate ────────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
class Memory:
|
|
236
|
+
def __init__(self, workspace: str):
|
|
237
|
+
self.workspace = str(workspace)
|
|
238
|
+
self.daily = DailyNotes(self.workspace)
|
|
239
|
+
self.weekly = WeeklyDigest(self.workspace)
|
|
240
|
+
self.longterm = LongTermMemory(self.workspace)
|
|
241
|
+
self.knowledge = KnowledgeGraph(self.workspace)
|
|
242
|
+
|
|
243
|
+
def init(self):
|
|
244
|
+
self.daily.init(); self.weekly.init(); self.longterm.init(); self.knowledge.init()
|
|
245
|
+
|
|
246
|
+
def status(self):
|
|
247
|
+
return {
|
|
248
|
+
"dailyNotes": len(self.daily.list_notes()),
|
|
249
|
+
"weeklyDigests": len(self.weekly.list_digests()),
|
|
250
|
+
"longtermLessons": len(self.longterm.get_all().get("lessons", [])),
|
|
251
|
+
"longtermPatterns": len(self.longterm.get_all().get("patterns", [])),
|
|
252
|
+
"longtermDecisions": len(self.longterm.get_all().get("decisions", [])),
|
|
253
|
+
"entities": len(self.knowledge.list_entities()),
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
def search(self, query):
|
|
257
|
+
return {"longterm": self.longterm.search(query), "entities": self.knowledge.search_entities(query)}
|