@letta-ai/letta-code 0.23.11 → 0.24.0

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.
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Show the merged Letta Code harness configuration.
4
+
5
+ Displays permissions, hooks, and per-agent settings across user/project/local
6
+ scopes with their source scope annotated.
7
+
8
+ Usage:
9
+ python3 show_config.py
10
+ python3 show_config.py --cwd /path/to/project
11
+ python3 show_config.py --json
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+
19
+
20
+ def get_settings_paths(working_directory: str) -> list[tuple[str, Path]]:
21
+ """Return (scope, path) in precedence order (lowest to highest)."""
22
+ return [
23
+ ("user", Path.home() / ".letta" / "settings.json"),
24
+ ("project", Path(working_directory) / ".letta" / "settings.json"),
25
+ ("local", Path(working_directory) / ".letta" / "settings.local.json"),
26
+ ]
27
+
28
+
29
+ def load_settings(path: Path) -> dict:
30
+ if not path.exists():
31
+ return {}
32
+ try:
33
+ with open(path) as f:
34
+ return json.load(f)
35
+ except (json.JSONDecodeError, IOError):
36
+ return {}
37
+
38
+
39
+ def format_permissions(
40
+ all_settings: list[tuple[str, dict]], as_json: bool
41
+ ) -> dict | None:
42
+ """Collect permissions from all scopes with sources."""
43
+ rules: dict[str, list[tuple[str, str]]] = {"allow": [], "deny": [], "ask": []}
44
+ for scope, settings in all_settings:
45
+ perms = settings.get("permissions", {})
46
+ for rule_type in ["allow", "deny", "ask"]:
47
+ for rule in perms.get(rule_type, []):
48
+ rules[rule_type].append((rule, scope))
49
+
50
+ if as_json:
51
+ return {
52
+ t: [{"rule": r, "scope": s} for r, s in rules[t]] for t in rules if rules[t]
53
+ }
54
+
55
+ total = sum(len(v) for v in rules.values())
56
+ print("=" * 60)
57
+ print(f"PERMISSIONS ({total} rules)")
58
+ print("=" * 60)
59
+ if total == 0:
60
+ print(" (none)")
61
+ else:
62
+ for rule_type in ["allow", "deny", "ask"]:
63
+ if rules[rule_type]:
64
+ print(f"\n {rule_type.upper()}:")
65
+ for rule, scope in rules[rule_type]:
66
+ print(f" [{scope:7}] {rule}")
67
+ print()
68
+ return None
69
+
70
+
71
+ def format_hooks(all_settings: list[tuple[str, dict]], as_json: bool) -> dict | None:
72
+ """Collect hooks from all scopes with sources."""
73
+ collected: dict[str, list[tuple[str, dict]]] = {}
74
+ for scope, settings in all_settings:
75
+ hooks = settings.get("hooks", {})
76
+ if not isinstance(hooks, dict):
77
+ continue
78
+ for event, entries in hooks.items():
79
+ if event == "disabled":
80
+ continue
81
+ if not isinstance(entries, list):
82
+ continue
83
+ collected.setdefault(event, []).extend((scope, e) for e in entries)
84
+
85
+ if as_json:
86
+ return {
87
+ event: [
88
+ {"scope": scope, **entry}
89
+ for scope, entry in entries
90
+ ]
91
+ for event, entries in collected.items()
92
+ }
93
+
94
+ total_groups = sum(len(v) for v in collected.values())
95
+ print("=" * 60)
96
+ print(f"HOOKS ({len(collected)} events, {total_groups} groups)")
97
+ print("=" * 60)
98
+ if not collected:
99
+ print(" (none)")
100
+ else:
101
+ for event, entries in sorted(collected.items()):
102
+ print(f"\n {event}:")
103
+ for scope, entry in entries:
104
+ matcher = entry.get("matcher", "(no matcher)")
105
+ hook_list = entry.get("hooks", [])
106
+ for h in hook_list:
107
+ htype = h.get("type", "?")
108
+ detail = h.get("command") or h.get("prompt", "")
109
+ detail = (
110
+ (detail[:60] + "...") if len(detail) > 60 else detail
111
+ )
112
+ print(f" [{scope:7}] matcher={matcher:15} {htype}: {detail}")
113
+ print()
114
+ return None
115
+
116
+
117
+ def format_agents(all_settings: list[tuple[str, dict]], as_json: bool) -> dict | None:
118
+ """Collect per-agent settings (only from user settings.json)."""
119
+ agents = []
120
+ for scope, settings in all_settings:
121
+ for a in settings.get("agents", []):
122
+ agents.append({"scope": scope, **a})
123
+
124
+ if as_json:
125
+ return agents
126
+
127
+ print("=" * 60)
128
+ print(f"PER-AGENT SETTINGS ({len(agents)} entries)")
129
+ print("=" * 60)
130
+ if not agents:
131
+ print(" (none)")
132
+ else:
133
+ for a in agents:
134
+ scope = a.get("scope", "?")
135
+ aid = a.get("agentId", "?")
136
+ print(f"\n [{scope:7}] {aid}")
137
+ for k in ("pinned", "memfs", "toolset", "systemPromptPreset", "baseUrl"):
138
+ if k in a:
139
+ val = a[k]
140
+ if isinstance(val, dict):
141
+ val = json.dumps(val)
142
+ print(f" {k}: {val}")
143
+ print()
144
+ return None
145
+
146
+
147
+ def format_settings_files(working_directory: str) -> None:
148
+ print("=" * 60)
149
+ print("SETTINGS FILES")
150
+ print("=" * 60)
151
+ for scope, path in get_settings_paths(working_directory):
152
+ exists = "✓" if path.exists() else "✗"
153
+ print(f" {exists} [{scope:7}] {path}")
154
+ print()
155
+
156
+
157
+ def main():
158
+ parser = argparse.ArgumentParser(
159
+ description="Show Letta Code harness configuration (permissions, hooks, agents)"
160
+ )
161
+ parser.add_argument(
162
+ "--cwd",
163
+ default=os.getcwd(),
164
+ help="Working directory for project/local scope (default: cwd)",
165
+ )
166
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
167
+ parser.add_argument(
168
+ "--section",
169
+ choices=["permissions", "hooks", "agents", "all"],
170
+ default="all",
171
+ help="Which section to show (default: all)",
172
+ )
173
+
174
+ args = parser.parse_args()
175
+
176
+ all_settings = [
177
+ (scope, load_settings(path))
178
+ for scope, path in get_settings_paths(args.cwd)
179
+ ]
180
+
181
+ if args.json:
182
+ output: dict = {}
183
+ if args.section in ("permissions", "all"):
184
+ perms = format_permissions(all_settings, as_json=True)
185
+ if perms:
186
+ output["permissions"] = perms
187
+ if args.section in ("hooks", "all"):
188
+ hooks = format_hooks(all_settings, as_json=True)
189
+ if hooks:
190
+ output["hooks"] = hooks
191
+ if args.section in ("agents", "all"):
192
+ agents = format_agents(all_settings, as_json=True)
193
+ if agents:
194
+ output["agents"] = agents
195
+ print(json.dumps(output, indent=2))
196
+ else:
197
+ print(f"\nLetta Code Harness Configuration")
198
+ print(f"Working directory: {args.cwd}\n")
199
+ if args.section == "all":
200
+ format_settings_files(args.cwd)
201
+ if args.section in ("permissions", "all"):
202
+ format_permissions(all_settings, as_json=False)
203
+ if args.section in ("hooks", "all"):
204
+ format_hooks(all_settings, as_json=False)
205
+ if args.section in ("agents", "all"):
206
+ format_agents(all_settings, as_json=False)
207
+ print("Precedence (highest to lowest): local > project > user\n")
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
@@ -21,8 +21,10 @@ When memfs is enabled, the Letta Code CLI automatically:
21
21
  2. Clones the repo into `~/.letta/agents/<agent-id>/memory/` (git root is the memory directory)
22
22
  3. Configures a **local** credential helper in `memory/.git/config` (so `git push`/`git pull` work without auth ceremony)
23
23
  4. Installs a **pre-commit hook** that validates frontmatter before each commit (see below)
24
- 5. On subsequent startups: pulls latest changes, reconfigures credentials and hook (self-healing)
25
- 6. During sessions: periodically checks `git status` and reminds you (the agent) to commit/push if dirty
24
+ 5. Installs a **post-commit hook** that pushes commits to an optional additional remote (see "Additional memory-repository remote" below)
25
+ 6. Sets canonical local git identity (`letta.agentId`, `user.name`, `user.email`) so direct `git commit` from the agent's shell attributes correctly to the agent — not the operator's global git identity
26
+ 7. On subsequent startups: pulls latest changes, reconfigures credentials, hooks, and identity (self-healing)
27
+ 8. During sessions: periodically checks `git status` and reminds you (the agent) to commit/push if dirty
26
28
 
27
29
  If any of these steps fail, you can replicate them manually using the sections below.
28
30
 
@@ -83,6 +85,33 @@ Block content goes here.
83
85
 
84
86
  If the hook rejects a commit, read the error message — it tells you exactly which file and which rule was violated. Fix the file and retry.
85
87
 
88
+ ## Additional Memory-Repository Remote
89
+
90
+ In addition to pushing to the Letta server, you can push every commit to a second git remote — e.g. a private GitHub repo — so you have a backup or a copy you can browse with regular tools.
91
+
92
+ **Via the slash command (recommended):**
93
+ ```
94
+ /memory-repository set git@github.com:you/my-memory.git
95
+ /memory-repository status
96
+ /memory-repository push # force a push now, e.g. after a network failure
97
+ /memory-repository unset # stop pushing
98
+ ```
99
+
100
+ **How it works:**
101
+ - `/memory-repository set <url>` writes the URL to `letta.memoryRepository.url` in the memfs repo's local `.git/config` and installs a `post-commit` hook.
102
+ - After every commit, the hook reads `letta.memoryRepository.url` and asynchronously pushes to it in the background. Commits are never blocked by push failures.
103
+ - Push output and exit codes are appended to `.git/memory-repository-push.log` — visible via `/memory-repository status`.
104
+ - The setting is **per-repo**, so each agent on a machine has its own independent configuration.
105
+
106
+ **Auth:** uses your existing git credentials — SSH keys, credential helpers, or tokens in the URL. Letta does not store tokens for this feature. If you're pushing to GitHub, SSH is easiest.
107
+
108
+ **Manual equivalent (without the slash command):**
109
+ ```bash
110
+ cd ~/.letta/agents/<agent-id>/memory
111
+ git config --local letta.memoryRepository.url git@github.com:you/my-memory.git
112
+ # Hook is installed automatically by the CLI on startup; no manual install needed.
113
+ ```
114
+
86
115
  ## Clone Agent Memory
87
116
 
88
117
  ```bash