@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +178 -0
  3. package/deploy/ack_monitor.py +140 -0
  4. package/deploy/ack_watchdog.py +110 -0
  5. package/deploy/agent-character-monitor.service +21 -0
  6. package/deploy/agent-character-watchdog.service +21 -0
  7. package/deploy/agent-enforcer-proof.service +18 -0
  8. package/deploy/agent-enforcer.service +53 -0
  9. package/deploy/deploy-ack-services.sh +47 -0
  10. package/deploy/deploy-agent-enforcer.sh +133 -0
  11. package/deploy/proof-self-respawn.sh +59 -0
  12. package/node/bin/ack.js +383 -0
  13. package/node/bin/aik.js +520 -0
  14. package/node/bin/install.js +409 -0
  15. package/node/enforcer/agent_enforcer_daemon.js +690 -0
  16. package/node/examples/.agent/constitution.yaml +38 -0
  17. package/node/examples/.agent/enforcer.yaml +33 -0
  18. package/node/examples/.agent/habits/affirm_character_each_action.yaml +9 -0
  19. package/node/examples/.agent/habits/audit_not_silent.yaml +9 -0
  20. package/node/examples/.agent/habits/binding_map_one_core.yaml +9 -0
  21. package/node/examples/.agent/habits/character_hash_visible.yaml +9 -0
  22. package/node/examples/.agent/habits/check_duplication_before_debug.yaml +9 -0
  23. package/node/examples/.agent/habits/complete-thoroughly.yaml +16 -0
  24. package/node/examples/.agent/habits/consistent-info-across-files.yaml +18 -0
  25. package/node/examples/.agent/habits/document-for-next-agent.yaml +15 -0
  26. package/node/examples/.agent/habits/documented_rollback.yaml +9 -0
  27. package/node/examples/.agent/habits/drift_signal_detection.yaml +9 -0
  28. package/node/examples/.agent/habits/due-diligence.yaml +16 -0
  29. package/node/examples/.agent/habits/enterprise-grade-modular.yaml +16 -0
  30. package/node/examples/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  31. package/node/examples/.agent/habits/forever_one_idea.yaml +9 -0
  32. package/node/examples/.agent/habits/graceful_degradation.yaml +9 -0
  33. package/node/examples/.agent/habits/idempotent_operations.yaml +9 -0
  34. package/node/examples/.agent/habits/interactive-no-stalls.yaml +16 -0
  35. package/node/examples/.agent/habits/layered_not_rewritten.yaml +9 -0
  36. package/node/examples/.agent/habits/lossless_consolidation.yaml +9 -0
  37. package/node/examples/.agent/habits/no-credential-leak.yaml +41 -0
  38. package/node/examples/.agent/habits/no-deception.yaml +14 -0
  39. package/node/examples/.agent/habits/no_hold_narration.yaml +9 -0
  40. package/node/examples/.agent/habits/one_concern_per_file.yaml +9 -0
  41. package/node/examples/.agent/habits/optimized-robust.yaml +16 -0
  42. package/node/examples/.agent/habits/registered_plugin_not_string.yaml +9 -0
  43. package/node/examples/.agent/habits/rename_as_layer_op.yaml +9 -0
  44. package/node/examples/.agent/habits/resolve-root-cause.yaml +15 -0
  45. package/node/examples/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  46. package/node/examples/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  47. package/node/examples/.agent/habits/safe_file_permissions.yaml +9 -0
  48. package/node/examples/.agent/habits/self-healing-portability.yaml +14 -0
  49. package/node/examples/.agent/habits/shippable-pride.yaml +15 -0
  50. package/node/examples/.agent/habits/single_source_of_truth.yaml +9 -0
  51. package/node/examples/.agent/habits/test_of_forever.yaml +9 -0
  52. package/node/examples/.agent/habits/timeout_and_retry.yaml +9 -0
  53. package/node/examples/.agent/habits/track_defects_openly.yaml +9 -0
  54. package/node/examples/.agent/habits/validate-against-real-source.yaml +14 -0
  55. package/node/examples/.agent/habits/verify-against-docs.yaml +15 -0
  56. package/node/examples/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  57. package/node/examples/.agent/habits/versioning-discipline.yaml +18 -0
  58. package/node/package.json +53 -0
  59. package/node/src/enforcer/client.js +149 -0
  60. package/node/src/hooks/character.js +286 -0
  61. package/node/src/index.js +24 -0
  62. package/node/src/knowledge/indexer.js +486 -0
  63. package/node/src/knowledge/semantic.js +154 -0
  64. package/node/src/memory/index.js +355 -0
  65. package/node/tests/character.test.js +80 -0
  66. package/node/tests/habit-create.test.js +67 -0
  67. package/node/tests/habits-default.test.js +68 -0
  68. package/node/tests/install-chain.test.js +19 -0
  69. package/node/tests/install.test.js +104 -0
  70. package/package.json +65 -0
  71. package/python/agent_character_kit/__init__.py +388 -0
  72. package/python/agent_character_kit/__main__.py +333 -0
  73. package/python/agent_character_kit/_yaml.py +33 -0
  74. package/python/agent_character_kit/enforcer.py +379 -0
  75. package/python/agent_character_kit/memory.py +257 -0
  76. package/python/agent_character_kit/semantic.py +126 -0
  77. package/python/example_workspace/.agent/habits/affirm_character_each_action.yaml +9 -0
  78. package/python/example_workspace/.agent/habits/audit_not_silent.yaml +9 -0
  79. package/python/example_workspace/.agent/habits/binding_map_one_core.yaml +9 -0
  80. package/python/example_workspace/.agent/habits/character_hash_visible.yaml +9 -0
  81. package/python/example_workspace/.agent/habits/check_duplication_before_debug.yaml +9 -0
  82. package/python/example_workspace/.agent/habits/complete-thoroughly.yaml +16 -0
  83. package/python/example_workspace/.agent/habits/consistent-info-across-files.yaml +18 -0
  84. package/python/example_workspace/.agent/habits/document-for-next-agent.yaml +15 -0
  85. package/python/example_workspace/.agent/habits/documented_rollback.yaml +9 -0
  86. package/python/example_workspace/.agent/habits/drift_signal_detection.yaml +9 -0
  87. package/python/example_workspace/.agent/habits/due-diligence.yaml +16 -0
  88. package/python/example_workspace/.agent/habits/enterprise-grade-modular.yaml +16 -0
  89. package/python/example_workspace/.agent/habits/fail_closed_tamper_evident.yaml +9 -0
  90. package/python/example_workspace/.agent/habits/forever_one_idea.yaml +9 -0
  91. package/python/example_workspace/.agent/habits/graceful_degradation.yaml +9 -0
  92. package/python/example_workspace/.agent/habits/idempotent_operations.yaml +9 -0
  93. package/python/example_workspace/.agent/habits/interactive-no-stalls.yaml +16 -0
  94. package/python/example_workspace/.agent/habits/layered_not_rewritten.yaml +9 -0
  95. package/python/example_workspace/.agent/habits/lossless_consolidation.yaml +9 -0
  96. package/python/example_workspace/.agent/habits/no-credential-leak.yaml +41 -0
  97. package/python/example_workspace/.agent/habits/no-deception.yaml +14 -0
  98. package/python/example_workspace/.agent/habits/no_hold_narration.yaml +9 -0
  99. package/python/example_workspace/.agent/habits/one_concern_per_file.yaml +9 -0
  100. package/python/example_workspace/.agent/habits/optimized-robust.yaml +16 -0
  101. package/python/example_workspace/.agent/habits/registered_plugin_not_string.yaml +9 -0
  102. package/python/example_workspace/.agent/habits/rename_as_layer_op.yaml +9 -0
  103. package/python/example_workspace/.agent/habits/resolve-root-cause.yaml +15 -0
  104. package/python/example_workspace/.agent/habits/rigorous-commits-no-push.yaml +18 -0
  105. package/python/example_workspace/.agent/habits/safe-deletion-via-trash.yaml +20 -0
  106. package/python/example_workspace/.agent/habits/safe_file_permissions.yaml +9 -0
  107. package/python/example_workspace/.agent/habits/self-healing-portability.yaml +14 -0
  108. package/python/example_workspace/.agent/habits/shippable-pride.yaml +15 -0
  109. package/python/example_workspace/.agent/habits/single_source_of_truth.yaml +9 -0
  110. package/python/example_workspace/.agent/habits/test_of_forever.yaml +9 -0
  111. package/python/example_workspace/.agent/habits/timeout_and_retry.yaml +9 -0
  112. package/python/example_workspace/.agent/habits/track_defects_openly.yaml +9 -0
  113. package/python/example_workspace/.agent/habits/validate-against-real-source.yaml +14 -0
  114. package/python/example_workspace/.agent/habits/verify-against-docs.yaml +15 -0
  115. package/python/example_workspace/.agent/habits/verify-functionality-not-syntax.yaml +15 -0
  116. package/python/example_workspace/.agent/habits/versioning-discipline.yaml +18 -0
  117. package/python/hermes_plugin/README.md +63 -0
  118. package/python/hermes_plugin/__init__.py +367 -0
  119. package/python/hermes_plugin/config.yaml +20 -0
  120. package/python/hermes_plugin/plugin.yaml +7 -0
  121. package/python/hermes_plugin/test_plugin.py +166 -0
  122. package/python/pyproject.toml +19 -0
  123. 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)}