footprint-trace 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -25,19 +25,18 @@ trace → chat with claude → collect → train → install → /footprint in O
25
25
 
26
26
  | | trace / collect | train / serve |
27
27
  |---|---|---|
28
- | **macOS (Apple Silicon)** | ✅ | ✅ (MLX) |
29
- | **macOS (Intel)** | ✅ | |
30
- | **Linux** | ✅ | (for now) |
31
- | **Windows** | ✅ | (for now) |
28
+ | **macOS (Apple Silicon)** | ✅ | ✅ MLX backend |
29
+ | **Linux** | ✅ | torch backend (CUDA or CPU) |
30
+ | **Windows** | ✅ | torch backend (CUDA or CPU) |
31
+ | **macOS (Intel)** | ✅ | torch backend (CPU) |
32
32
 
33
33
  - [Claude Code](https://claude.com/claude-code) installed and used at least once
34
34
  - Python 3.9+
35
35
  - Node.js 18+ (only if installing via npm)
36
36
 
37
- Training and serving use [MLX](https://github.com/ml-explore/mlx), which is
38
- Apple-Silicon-only today. On Linux/Windows you can still trace and collect —
39
- your `data/` folder is portable; train it on any Apple Silicon Mac. Other
40
- backends (llama.cpp / unsloth) are welcome contributions.
37
+ footprint picks the backend automatically: [MLX](https://github.com/ml-explore/mlx)
38
+ on Apple Silicon, PyTorch + PEFT everywhere else (uses CUDA when available;
39
+ CPU works but trains slowly prefer a GPU or drop `FOOTPRINT_ITERS`).
41
40
 
42
41
  ## Install
43
42
 
@@ -61,7 +60,7 @@ python3 footprint.py setup
61
60
  npm install -g footprint-trace # or the git clone above
62
61
  ```
63
62
 
64
- `setup` skips MLX automatically. `trace` and `collect` work; copy `data/` to a Mac to train.
63
+ First run installs the torch backend (one-time, ~2 GB). CUDA GPU picked up automatically.
65
64
 
66
65
  ### Windows
67
66
 
@@ -71,7 +70,7 @@ Use PowerShell (Python 3 from python.org or the Microsoft Store):
71
70
  npm install -g footprint-trace
72
71
  ```
73
72
 
74
- Same scope as Linux: trace + collect. WSL2 works the same way.
73
+ Same as Linux: full trace/collect/train/serve via the torch backend. WSL2 also works.
75
74
 
76
75
  ## Getting started
77
76
 
@@ -100,14 +99,15 @@ footprint collect # transcripts -> data/train.jsonl + data/valid.jsonl
100
99
  footprint train # LoRA fine-tune (~10 min, downloads ~1 GB base model first time)
101
100
  ```
102
101
 
103
- **4. Install the always-on server (macOS).**
102
+ **4. Install the always-on server.**
104
103
 
105
104
  ```sh
106
105
  footprint install
107
106
  ```
108
107
 
109
- This registers a launchd agent the server starts at login, restarts if it
110
- crashes, and you never run it by hand. It also wires up OpenCode:
108
+ This registers the server with your OS launchd (macOS), systemd (Linux) or
109
+ Task Scheduler (Windows) so it starts at login, restarts if it crashes, and
110
+ you never run it by hand. It also wires up OpenCode:
111
111
 
112
112
  - provider `footprint` at `http://127.0.0.1:8399/v1`
113
113
  - a `/footprint <task>` command inside OpenCode
@@ -136,7 +136,7 @@ Or point any OpenAI-compatible tool at the server:
136
136
  | `footprint trace` | arm tracing (run before starting Claude) |
137
137
  | `footprint collect [dir]` | parse transcripts of a project (default: current dir; unknown dir = all projects) |
138
138
  | `footprint train` | LoRA fine-tune on collected data |
139
- | `footprint install` | launchd auto-server + OpenCode integration (macOS) |
139
+ | `footprint install` | auto-start server (launchd / systemd / Task Scheduler) + OpenCode integration |
140
140
  | `footprint serve` | run the server manually (fallback; `install` makes this unnecessary) |
141
141
  | `footprint status` | model, example count, adapter, tracing, server state |
142
142
 
@@ -144,15 +144,15 @@ Or point any OpenAI-compatible tool at the server:
144
144
 
145
145
  | Env var | Default | |
146
146
  |---|---|---|
147
- | `FOOTPRINT_MODEL` | `mlx-community/Qwen2.5-Coder-1.5B-Instruct-4bit` | any MLX chat model |
147
+ | `FOOTPRINT_MODEL` | `mlx-community/Qwen2.5-Coder-1.5B-Instruct-4bit` (mac) / `Qwen/Qwen2.5-Coder-1.5B-Instruct` | any chat model of the backend |
148
148
  | `FOOTPRINT_ITERS` | `300` | training iterations |
149
149
  | `FOOTPRINT_PORT` | `8399` | server port |
150
150
 
151
151
  ## Troubleshooting
152
152
 
153
153
  - **`no transcripts found`** — either you haven't used Claude Code in this project, or the trace marker is newer than all sessions. Chat first, or `rm ~/.claude/footprint-trace`.
154
- - **server not responding** — check `/tmp/footprint-serve.log`; `launchctl kickstart -k gui/$UID/com.footprint.serve` restarts it.
155
- - **`mlx_lm` import fails** — re-run `footprint setup` (Apple Silicon only).
154
+ - **server not responding** — macOS: `/tmp/footprint-serve.log`, `launchctl kickstart -k gui/$UID/com.footprint.serve`; Linux: `journalctl --user -u footprint`, `systemctl --user restart footprint`; Windows: `schtasks /Run /TN footprint-serve`.
155
+ - **backend import fails** (`mlx_lm` / `torch`) — re-run `footprint setup`.
156
156
  - **quality is rough** — more data beats more iterations: keep tracing, re-collect, re-train. 1.5B is small; try `FOOTPRINT_MODEL=mlx-community/Qwen2.5-Coder-7B-Instruct-4bit` if you have ≥16 GB RAM.
157
157
 
158
158
  ## Privacy
package/bin/footprint.js CHANGED
@@ -5,7 +5,10 @@ const fs = require("fs");
5
5
 
6
6
  const root = path.join(__dirname, "..");
7
7
  const script = path.join(root, "footprint.py");
8
- const venvPy = path.join(root, ".venv", "bin", "python");
8
+ const win = process.platform === "win32";
9
+ const venvPy = win
10
+ ? path.join(root, ".venv", "Scripts", "python.exe")
11
+ : path.join(root, ".venv", "bin", "python");
9
12
  const args = process.argv.slice(2);
10
13
 
11
14
  if (args.length === 0) {
@@ -13,8 +16,8 @@ if (args.length === 0) {
13
16
  }
14
17
 
15
18
  if (!fs.existsSync(venvPy)) {
16
- console.log("first run — setting up (venv + mlx-lm, takes a minute)…");
17
- const s = spawnSync("python3", [script, "setup"], { stdio: "inherit" });
19
+ console.log("first run — setting up (venv + model backend)…");
20
+ const s = spawnSync(win ? "python" : "python3", [script, "setup"], { stdio: "inherit" });
18
21
  if (s.status) process.exit(s.status);
19
22
  }
20
23
 
package/footprint.py CHANGED
@@ -1,216 +1,6 @@
1
1
  #!/usr/bin/env python3
2
- """footprint learn a local SLM from your Claude Code transcripts.
3
-
4
- Commands:
5
- setup create .venv, install mlx-lm, link /footprint skill
6
- trace arm tracing: sessions from now on become training data (run before chatting)
7
- collect [project] parse ~/.claude/projects transcripts -> data/{train,valid}.jsonl
8
- train LoRA fine-tune base model on collected data
9
- serve OpenAI-compatible API at localhost:8399 (launchd runs this; users don't)
10
- install launchd agent (server always on) + OpenCode /footprint command
11
- status what's collected/trained
12
- """
13
- import json, os, re, subprocess, sys, glob, random, time, plistlib
14
-
15
- ROOT = os.path.dirname(os.path.abspath(__file__))
16
- DATA = os.path.join(ROOT, "data")
17
- ADAPTERS = os.path.join(ROOT, "adapters")
18
- PROJECTS = os.path.expanduser("~/.claude/projects")
19
- TRACE_MARK = os.path.expanduser("~/.claude/footprint-trace")
20
- LAUNCHD = os.path.expanduser("~/Library/LaunchAgents/com.footprint.serve.plist")
21
- OPENCODE = os.path.expanduser("~/.config/opencode")
22
- MODEL = os.environ.get("FOOTPRINT_MODEL", "mlx-community/Qwen2.5-Coder-1.5B-Instruct-4bit")
23
- CTX_BUDGET = 6000 # chars of history per training example
24
- RESULT_CAP = 1500 # chars kept per tool result
25
-
26
- SYSTEM = ("You are footprint, a local coding agent trained on Claude Code sessions. "
27
- "To act, emit tool calls as <tool name=\"NAME\">{json args}</tool>. "
28
- "Say DONE when the task is complete.")
29
-
30
-
31
- def _text(content, cap=None):
32
- """Flatten a Claude message content (str or block list) to training text."""
33
- if isinstance(content, str):
34
- out = content
35
- else:
36
- parts = []
37
- for b in content or []:
38
- t = b.get("type")
39
- if t == "text":
40
- parts.append(b["text"])
41
- elif t == "tool_use":
42
- parts.append(f'<tool name="{b["name"]}">{json.dumps(b.get("input", {}))}</tool>')
43
- elif t == "tool_result":
44
- c = b.get("content")
45
- c = "".join(x.get("text", "") for x in c) if isinstance(c, list) else str(c or "")
46
- parts.append(f"<tool_result>{c[:RESULT_CAP]}</tool_result>")
47
- out = "\n".join(p for p in parts if p.strip())
48
- out = re.sub(r"<system-reminder>.*?</system-reminder>", "", out, flags=re.S)
49
- out = re.sub(r"<local-command-caveat>.*?</local-command-caveat>", "", out, flags=re.S)
50
- return (out[:cap] if cap else out).strip()
51
-
52
-
53
- def parse_session(path):
54
- """One transcript file -> list of alternating {role, content} messages."""
55
- msgs = []
56
- for line in open(path, errors="replace"):
57
- try:
58
- rec = json.loads(line)
59
- except json.JSONDecodeError:
60
- continue
61
- if rec.get("type") not in ("user", "assistant") or rec.get("isSidechain") or rec.get("isMeta"):
62
- continue
63
- m = rec.get("message") or {}
64
- role = m.get("role")
65
- txt = _text(m.get("content"))
66
- if not role or not txt:
67
- continue
68
- if msgs and msgs[-1]["role"] == role:
69
- msgs[-1]["content"] += "\n" + txt
70
- else:
71
- msgs.append({"role": role, "content": txt})
72
- return msgs
73
-
74
-
75
- def trace():
76
- """Arm tracing. Claude Code already logs every session to ~/.claude/projects;
77
- the marker makes collect use only sessions started after this moment."""
78
- os.makedirs(os.path.dirname(TRACE_MARK), exist_ok=True)
79
- with open(TRACE_MARK, "w") as f:
80
- f.write(str(time.time()))
81
- print("tracing armed. chat with Claude Code as usual — those sessions become training data.\n"
82
- "then: footprint.py collect && footprint.py train\n"
83
- "(delete ~/.claude/footprint-trace to collect ALL history again)")
84
-
85
-
86
- def collect(project=None):
87
- since = float(open(TRACE_MARK).read()) if os.path.exists(TRACE_MARK) else 0.0
88
- slug = "-" + os.path.abspath(project or os.getcwd()).strip("/").replace("/", "-").replace(".", "-")
89
- dirs = [os.path.join(PROJECTS, slug)] if os.path.isdir(os.path.join(PROJECTS, slug)) \
90
- else glob.glob(os.path.join(PROJECTS, "*"))
91
- examples = []
92
- for d in dirs:
93
- for f in glob.glob(os.path.join(d, "*.jsonl")):
94
- if os.path.getmtime(f) < since:
95
- continue
96
- msgs = parse_session(f)
97
- # one example per assistant turn, with preceding context under budget
98
- for i, m in enumerate(msgs):
99
- if m["role"] != "assistant":
100
- continue
101
- ctx, size = [], 0
102
- for prev in reversed(msgs[:i]):
103
- size += len(prev["content"])
104
- if size > CTX_BUDGET:
105
- break
106
- ctx.insert(0, prev)
107
- if not ctx or ctx[0]["role"] != "user":
108
- continue
109
- examples.append({"messages": [{"role": "system", "content": SYSTEM}, *ctx, m]})
110
- if not examples:
111
- hint = " (trace marker active — only sessions after `trace` count; delete ~/.claude/footprint-trace for all history)" if since else ""
112
- sys.exit(f"no transcripts found under {PROJECTS}{hint}")
113
- random.seed(0)
114
- random.shuffle(examples)
115
- n_valid = max(1, len(examples) // 10)
116
- os.makedirs(DATA, exist_ok=True)
117
- for name, chunk in [("valid", examples[:n_valid]), ("train", examples[n_valid:])]:
118
- with open(os.path.join(DATA, f"{name}.jsonl"), "w") as fh:
119
- for e in chunk:
120
- fh.write(json.dumps(e) + "\n")
121
- print(f"collected {len(examples)} examples ({len(examples)-n_valid} train / {n_valid} valid) -> {DATA}")
122
-
123
-
124
- def train():
125
- if not os.path.exists(os.path.join(DATA, "train.jsonl")):
126
- sys.exit("no data. run: footprint.py collect")
127
- subprocess.run([sys.executable, "-m", "mlx_lm", "lora", "--model", MODEL, "--train",
128
- "--data", DATA, "--adapter-path", ADAPTERS,
129
- "--iters", os.environ.get("FOOTPRINT_ITERS", "300"),
130
- "--max-seq-length", "4096", "--batch-size", "1"], check=True)
131
- print(f"adapter saved -> {ADAPTERS}")
132
-
133
-
134
- def serve():
135
- port = os.environ.get("FOOTPRINT_PORT", "8399")
136
- args = [sys.executable, "-m", "mlx_lm", "server", "--model", MODEL,
137
- "--host", "127.0.0.1", "--port", port]
138
- if os.path.exists(os.path.join(ADAPTERS, "adapters.safetensors")):
139
- args += ["--adapter-path", ADAPTERS]
140
- else:
141
- print("(no adapter yet — serving base model; run train first for footprint behavior)")
142
- print(f"OpenAI-compatible API: http://127.0.0.1:{port}/v1 (model name: footprint)\n"
143
- "point Cursor / OpenCode / Codex / any OpenAI-compatible client here, api key: none")
144
- subprocess.run(args, check=True)
145
-
146
-
147
- def install():
148
- """Server runs itself (launchd, KeepAlive) + OpenCode /footprint command. User never runs serve."""
149
- if sys.platform != "darwin":
150
- sys.exit("install is macOS-only (launchd + MLX). on Linux/Windows run serve manually once MLX alternatives land.")
151
- py = os.path.join(ROOT, ".venv", "bin", "python")
152
- if not os.path.exists(py):
153
- sys.exit("no .venv. run: python3 footprint.py setup")
154
- port = os.environ.get("FOOTPRINT_PORT", "8399")
155
- os.makedirs(os.path.dirname(LAUNCHD), exist_ok=True)
156
- with open(LAUNCHD, "wb") as f:
157
- plistlib.dump({"Label": "com.footprint.serve",
158
- "ProgramArguments": [py, os.path.join(ROOT, "footprint.py"), "serve"],
159
- "RunAtLoad": True, "KeepAlive": True,
160
- "StandardOutPath": "/tmp/footprint-serve.log",
161
- "StandardErrorPath": "/tmp/footprint-serve.log"}, f)
162
- subprocess.run(["launchctl", "unload", LAUNCHD], capture_output=True)
163
- subprocess.run(["launchctl", "load", "-w", LAUNCHD], check=True)
164
-
165
- cfg_path = os.path.join(OPENCODE, "opencode.json")
166
- cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {"$schema": "https://opencode.ai/config.json"}
167
- cfg.setdefault("provider", {})["footprint"] = {
168
- "npm": "@ai-sdk/openai-compatible", "name": "footprint",
169
- "options": {"baseURL": f"http://127.0.0.1:{port}/v1"},
170
- "models": {"footprint": {"name": "footprint"}}}
171
- os.makedirs(os.path.join(OPENCODE, "command"), exist_ok=True)
172
- with open(cfg_path, "w") as f:
173
- json.dump(cfg, f, indent=2)
174
- with open(os.path.join(OPENCODE, "command", "footprint.md"), "w") as f:
175
- f.write("---\ndescription: do task with footprint — local model trained on your Claude sessions\n"
176
- "model: footprint/footprint\n---\n$ARGUMENTS\n")
177
- print(f"installed:\n"
178
- f" server: launchd keeps http://127.0.0.1:{port}/v1 running (log: /tmp/footprint-serve.log)\n"
179
- f" opencode: /footprint <task> uses it; or pick model 'footprint' via /models\n"
180
- f" cursor/codex: base URL http://127.0.0.1:{port}/v1, any api key")
181
-
182
-
183
- def status():
184
- tr = os.path.join(DATA, "train.jsonl")
185
- n = sum(1 for _ in open(tr)) if os.path.exists(tr) else 0
186
- print(f"model: {MODEL}\nexamples: {n}\nadapter: "
187
- f"{'yes' if os.path.exists(os.path.join(ADAPTERS, 'adapters.safetensors')) else 'no'}\n"
188
- f"tracing: {'armed since ' + time.ctime(float(open(TRACE_MARK).read())) if os.path.exists(TRACE_MARK) else 'off (collect uses all history)'}\n"
189
- f"server: {'launchd-managed' if os.path.exists(LAUNCHD) else 'not installed (run install)'}")
190
-
191
-
192
- def setup():
193
- venv = os.path.join(ROOT, ".venv")
194
- if not os.path.exists(venv):
195
- subprocess.run([sys.executable, "-m", "venv", venv], check=True)
196
- if sys.platform == "darwin":
197
- subprocess.run([os.path.join(venv, "bin", "pip"), "install", "-q", "mlx-lm"], check=True)
198
- else:
199
- print("mlx-lm skipped (Apple Silicon only). trace/collect work here; "
200
- "copy data/ to a Mac for train/serve.")
201
- link = os.path.expanduser("~/.claude/skills/footprint")
202
- os.makedirs(os.path.dirname(link), exist_ok=True)
203
- if not os.path.exists(link):
204
- os.symlink(os.path.join(ROOT, "skills", "footprint"), link)
205
- print(f"ready. /footprint skill linked -> {link}\nuse: {venv}/bin/python footprint.py collect|train|run")
206
-
2
+ """footprint entry point real code lives in footprint_core/."""
3
+ from footprint_core.cli import main
207
4
 
208
5
  if __name__ == "__main__":
209
- cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
210
- {"collect": lambda: collect(sys.argv[2] if len(sys.argv) > 2 else None),
211
- "trace": trace,
212
- "train": train,
213
- "serve": serve,
214
- "install": install,
215
- "status": status,
216
- "setup": setup}.get(cmd, lambda: sys.exit(__doc__))()
6
+ main()
File without changes
@@ -0,0 +1,9 @@
1
+ from .. import config
2
+
3
+
4
+ def get():
5
+ if config.IS_MAC:
6
+ from . import mlx_backend as b
7
+ else:
8
+ from . import torch_backend as b
9
+ return b
@@ -0,0 +1,23 @@
1
+ """Apple Silicon backend: mlx-lm (train + OpenAI-compatible server)."""
2
+ import os, subprocess, sys
3
+
4
+ from .. import config
5
+
6
+
7
+ def train():
8
+ subprocess.run([sys.executable, "-m", "mlx_lm", "lora", "--model", config.MODEL, "--train",
9
+ "--data", config.DATA, "--adapter-path", config.ADAPTERS,
10
+ "--iters", config.ITERS, "--max-seq-length", "4096",
11
+ "--batch-size", "1", "--grad-checkpoint"], check=True)
12
+ print(f"adapter saved -> {config.ADAPTERS}")
13
+
14
+
15
+ def serve():
16
+ args = [sys.executable, "-m", "mlx_lm", "server", "--model", config.MODEL,
17
+ "--host", "127.0.0.1", "--port", config.PORT]
18
+ if os.path.exists(os.path.join(config.ADAPTERS, "adapters.safetensors")):
19
+ args += ["--adapter-path", config.ADAPTERS]
20
+ else:
21
+ print("(no adapter yet — serving base model; run train first for footprint behavior)")
22
+ print(f"OpenAI-compatible API: http://127.0.0.1:{config.PORT}/v1 (model name: footprint)")
23
+ subprocess.run(args, check=True)
@@ -0,0 +1,93 @@
1
+ """Linux/Windows backend: transformers + peft (CUDA if available, else CPU)."""
2
+ import json, os
3
+
4
+ from .. import config
5
+
6
+
7
+ def train():
8
+ from datasets import load_dataset
9
+ from peft import LoraConfig
10
+ from trl import SFTConfig, SFTTrainer
11
+ ds = load_dataset("json", data_files={"train": os.path.join(config.DATA, "train.jsonl"),
12
+ "valid": os.path.join(config.DATA, "valid.jsonl")})
13
+ trainer = SFTTrainer(
14
+ model=config.MODEL,
15
+ args=SFTConfig(output_dir=config.ADAPTERS, max_steps=int(config.ITERS),
16
+ per_device_train_batch_size=1, gradient_checkpointing=True,
17
+ max_length=4096, logging_steps=25, report_to=[]),
18
+ train_dataset=ds["train"], eval_dataset=ds["valid"],
19
+ peft_config=LoraConfig(r=8, lora_alpha=16, target_modules="all-linear"))
20
+ trainer.train()
21
+ trainer.save_model(config.ADAPTERS)
22
+ print(f"adapter saved -> {config.ADAPTERS}")
23
+
24
+
25
+ def serve():
26
+ import torch
27
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
28
+ from transformers import AutoModelForCausalLM, AutoTokenizer
29
+
30
+ tok = AutoTokenizer.from_pretrained(config.MODEL)
31
+ model = AutoModelForCausalLM.from_pretrained(config.MODEL, torch_dtype="auto", device_map="auto")
32
+ if config.has_adapter():
33
+ from peft import PeftModel
34
+ model = PeftModel.from_pretrained(model, config.ADAPTERS)
35
+ else:
36
+ print("(no adapter yet — serving base model; run train first for footprint behavior)")
37
+ model.eval()
38
+
39
+ def generate(messages, max_tokens, temperature):
40
+ ids = tok.apply_chat_template(messages, add_generation_prompt=True,
41
+ return_tensors="pt").to(model.device)
42
+ with torch.no_grad():
43
+ out = model.generate(ids, max_new_tokens=max_tokens, do_sample=temperature > 0,
44
+ temperature=max(temperature, 1e-3),
45
+ pad_token_id=tok.eos_token_id)
46
+ return tok.decode(out[0][ids.shape[1]:], skip_special_tokens=True)
47
+
48
+ class Handler(BaseHTTPRequestHandler):
49
+ def _json(self, obj, ctype="application/json"):
50
+ body = json.dumps(obj).encode()
51
+ self.send_response(200)
52
+ self.send_header("Content-Type", ctype)
53
+ self.send_header("Content-Length", str(len(body)))
54
+ self.end_headers()
55
+ self.wfile.write(body)
56
+
57
+ def do_GET(self):
58
+ if self.path.startswith("/v1/models"):
59
+ self._json({"object": "list", "data": [{"id": "footprint", "object": "model"}]})
60
+ else:
61
+ self.send_error(404)
62
+
63
+ def do_POST(self):
64
+ if self.path.rstrip("/") != "/v1/chat/completions":
65
+ self.send_error(404)
66
+ return
67
+ req = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
68
+ text = generate(req.get("messages", []), req.get("max_tokens") or 1024,
69
+ req.get("temperature") if req.get("temperature") is not None else 0.7)
70
+ if req.get("stream"):
71
+ # ponytail: whole answer as one SSE chunk; true token streaming if UX needs it
72
+ self.send_response(200)
73
+ self.send_header("Content-Type", "text/event-stream")
74
+ self.end_headers()
75
+ for payload in (
76
+ {"id": "fp", "object": "chat.completion.chunk", "model": "footprint",
77
+ "choices": [{"index": 0, "delta": {"role": "assistant", "content": text},
78
+ "finish_reason": None}]},
79
+ {"id": "fp", "object": "chat.completion.chunk", "model": "footprint",
80
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}):
81
+ self.wfile.write(f"data: {json.dumps(payload)}\n\n".encode())
82
+ self.wfile.write(b"data: [DONE]\n\n")
83
+ else:
84
+ self._json({"id": "fp", "object": "chat.completion", "model": "footprint",
85
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": text},
86
+ "finish_reason": "stop"}]})
87
+
88
+ def log_message(self, *a):
89
+ pass
90
+
91
+ print(f"OpenAI-compatible API: http://127.0.0.1:{config.PORT}/v1 (model name: footprint)\n"
92
+ f"device: {'cuda' if torch.cuda.is_available() else 'cpu'}")
93
+ ThreadingHTTPServer(("127.0.0.1", int(config.PORT)), Handler).serve_forever()
@@ -0,0 +1,29 @@
1
+ """footprint — learn a local SLM from your Claude Code transcripts.
2
+
3
+ Commands:
4
+ setup create .venv + install the platform backend (mlx / torch)
5
+ trace arm tracing: sessions from now on become training data (run before chatting)
6
+ collect [project] parse ~/.claude/projects transcripts -> data/{train,valid}.jsonl
7
+ train LoRA fine-tune base model on collected data
8
+ serve OpenAI-compatible API at localhost:8399 (install runs this for you)
9
+ install auto-start server (launchd / systemd / Task Scheduler) + OpenCode /footprint
10
+ status what's collected/trained
11
+ """
12
+ import os, sys
13
+
14
+ from . import config, service, transcripts
15
+ from .backends import get
16
+
17
+
18
+ def main(argv=None):
19
+ argv = sys.argv[1:] if argv is None else argv
20
+ cmd = argv[0] if argv else "status"
21
+ if cmd == "train" and not os.path.exists(os.path.join(config.DATA, "train.jsonl")):
22
+ sys.exit("no data. run: footprint collect")
23
+ {"collect": lambda: transcripts.collect(argv[1] if len(argv) > 1 else None),
24
+ "trace": service.trace,
25
+ "train": lambda: get().train(),
26
+ "serve": lambda: get().serve(),
27
+ "install": service.install,
28
+ "status": service.status,
29
+ "setup": service.setup}.get(cmd, lambda: sys.exit(__doc__))()
@@ -0,0 +1,33 @@
1
+ import os, sys
2
+
3
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4
+ DATA = os.path.join(ROOT, "data")
5
+ ADAPTERS = os.path.join(ROOT, "adapters")
6
+ PROJECTS = os.path.expanduser("~/.claude/projects")
7
+ TRACE_MARK = os.path.expanduser("~/.claude/footprint-trace")
8
+ OPENCODE = os.path.expanduser("~/.config/opencode")
9
+ LAUNCHD = os.path.expanduser("~/Library/LaunchAgents/com.footprint.serve.plist")
10
+ SYSTEMD = os.path.expanduser("~/.config/systemd/user/footprint.service")
11
+
12
+ IS_MAC = sys.platform == "darwin"
13
+ IS_WIN = sys.platform == "win32"
14
+ VENV_PY = os.path.join(ROOT, ".venv", "Scripts", "python.exe") if IS_WIN \
15
+ else os.path.join(ROOT, ".venv", "bin", "python")
16
+ ENTRY = os.path.join(ROOT, "footprint.py")
17
+
18
+ MODEL = os.environ.get("FOOTPRINT_MODEL",
19
+ "mlx-community/Qwen2.5-Coder-1.5B-Instruct-4bit" if IS_MAC
20
+ else "Qwen/Qwen2.5-Coder-1.5B-Instruct")
21
+ PORT = os.environ.get("FOOTPRINT_PORT", "8399")
22
+ ITERS = os.environ.get("FOOTPRINT_ITERS", "300")
23
+ CTX_BUDGET = 6000 # chars of history per training example
24
+ RESULT_CAP = 1500 # chars kept per tool result
25
+
26
+ SYSTEM = ("You are footprint, a local coding agent trained on Claude Code sessions. "
27
+ "To act, emit tool calls as <tool name=\"NAME\">{json args}</tool>. "
28
+ "Say DONE when the task is complete.")
29
+
30
+
31
+ def has_adapter():
32
+ return any(os.path.exists(os.path.join(ADAPTERS, f))
33
+ for f in ("adapters.safetensors", "adapter_model.safetensors"))
@@ -0,0 +1,97 @@
1
+ """trace / status / setup / install — everything that isn't parsing or the model."""
2
+ import json, os, plistlib, subprocess, sys, time
3
+
4
+ from . import config
5
+
6
+
7
+ def trace():
8
+ """Arm tracing. Claude Code already logs every session to ~/.claude/projects;
9
+ the marker makes collect use only sessions started after this moment."""
10
+ os.makedirs(os.path.dirname(config.TRACE_MARK), exist_ok=True)
11
+ with open(config.TRACE_MARK, "w") as f:
12
+ f.write(str(time.time()))
13
+ print("tracing armed. chat with Claude Code as usual — those sessions become training data.\n"
14
+ "then: footprint collect && footprint train\n"
15
+ "(delete ~/.claude/footprint-trace to collect ALL history again)")
16
+
17
+
18
+ def status():
19
+ tr = os.path.join(config.DATA, "train.jsonl")
20
+ n = sum(1 for _ in open(tr)) if os.path.exists(tr) else 0
21
+ installed = os.path.exists(config.LAUNCHD) if config.IS_MAC else \
22
+ os.path.exists(config.SYSTEMD) if not config.IS_WIN else \
23
+ subprocess.run(["schtasks", "/Query", "/TN", "footprint-serve"],
24
+ capture_output=True).returncode == 0
25
+ print(f"backend: {'mlx' if config.IS_MAC else 'torch'}\nmodel: {config.MODEL}\nexamples: {n}\n"
26
+ f"adapter: {'yes' if config.has_adapter() else 'no'}\n"
27
+ f"tracing: {'armed since ' + time.ctime(float(open(config.TRACE_MARK).read())) if os.path.exists(config.TRACE_MARK) else 'off (collect uses all history)'}\n"
28
+ f"server: {'auto-managed' if installed else 'not installed (run install)'}")
29
+
30
+
31
+ def setup():
32
+ venv = os.path.join(config.ROOT, ".venv")
33
+ if not os.path.exists(venv):
34
+ subprocess.run([sys.executable, "-m", "venv", venv], check=True)
35
+ pkgs = ["mlx-lm"] if config.IS_MAC else ["torch", "transformers", "peft", "trl", "datasets"]
36
+ if not config.IS_MAC:
37
+ print("installing torch + transformers stack (one-time, ~2 GB)…")
38
+ subprocess.run([config.VENV_PY, "-m", "pip", "install", "-q", *pkgs], check=True)
39
+ link = os.path.expanduser("~/.claude/skills/footprint")
40
+ try:
41
+ os.makedirs(os.path.dirname(link), exist_ok=True)
42
+ if not os.path.exists(link):
43
+ os.symlink(os.path.join(config.ROOT, "skills", "footprint"), link)
44
+ except OSError:
45
+ pass # Windows without symlink rights — skill is optional
46
+ print(f"ready. next: footprint trace")
47
+
48
+
49
+ def _opencode():
50
+ cfg_path = os.path.join(config.OPENCODE, "opencode.json")
51
+ cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {"$schema": "https://opencode.ai/config.json"}
52
+ cfg.setdefault("provider", {})["footprint"] = {
53
+ "npm": "@ai-sdk/openai-compatible", "name": "footprint",
54
+ "options": {"baseURL": f"http://127.0.0.1:{config.PORT}/v1"},
55
+ "models": {"footprint": {"name": "footprint"}}}
56
+ os.makedirs(os.path.join(config.OPENCODE, "command"), exist_ok=True)
57
+ with open(cfg_path, "w") as f:
58
+ json.dump(cfg, f, indent=2)
59
+ with open(os.path.join(config.OPENCODE, "command", "footprint.md"), "w") as f:
60
+ f.write("---\ndescription: do task with footprint — local model trained on your Claude sessions\n"
61
+ "model: footprint/footprint\n---\n$ARGUMENTS\n")
62
+
63
+
64
+ def install():
65
+ """Register the server with the OS so it runs itself. User never runs serve."""
66
+ if not os.path.exists(config.VENV_PY):
67
+ sys.exit("no .venv. run: footprint setup")
68
+ if config.IS_MAC:
69
+ os.makedirs(os.path.dirname(config.LAUNCHD), exist_ok=True)
70
+ with open(config.LAUNCHD, "wb") as f:
71
+ plistlib.dump({"Label": "com.footprint.serve",
72
+ "ProgramArguments": [config.VENV_PY, config.ENTRY, "serve"],
73
+ "RunAtLoad": True, "KeepAlive": True,
74
+ "StandardOutPath": "/tmp/footprint-serve.log",
75
+ "StandardErrorPath": "/tmp/footprint-serve.log"}, f)
76
+ subprocess.run(["launchctl", "unload", config.LAUNCHD], capture_output=True)
77
+ subprocess.run(["launchctl", "load", "-w", config.LAUNCHD], check=True)
78
+ how = "launchd (log: /tmp/footprint-serve.log)"
79
+ elif config.IS_WIN:
80
+ subprocess.run(["schtasks", "/Create", "/F", "/TN", "footprint-serve", "/SC", "ONLOGON",
81
+ "/TR", f'"{config.VENV_PY}" "{config.ENTRY}" serve'], check=True)
82
+ subprocess.run(["schtasks", "/Run", "/TN", "footprint-serve"], check=True)
83
+ how = "Task Scheduler (task: footprint-serve)"
84
+ else:
85
+ os.makedirs(os.path.dirname(config.SYSTEMD), exist_ok=True)
86
+ with open(config.SYSTEMD, "w") as f:
87
+ f.write("[Unit]\nDescription=footprint local model server\n\n"
88
+ f"[Service]\nExecStart={config.VENV_PY} {config.ENTRY} serve\nRestart=always\n\n"
89
+ "[Install]\nWantedBy=default.target\n")
90
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=True)
91
+ subprocess.run(["systemctl", "--user", "enable", "--now", "footprint"], check=True)
92
+ how = "systemd --user (journalctl --user -u footprint)"
93
+ _opencode()
94
+ print(f"installed:\n"
95
+ f" server: {how} keeps http://127.0.0.1:{config.PORT}/v1 running\n"
96
+ f" opencode: /footprint <task> uses it; or pick model 'footprint' via /models\n"
97
+ f" cursor/codex: base URL http://127.0.0.1:{config.PORT}/v1, any api key")
@@ -0,0 +1,95 @@
1
+ """Parse Claude Code transcripts into training examples."""
2
+ import glob, json, os, random, re, sys
3
+
4
+ from . import config
5
+
6
+
7
+ def _text(content, cap=None):
8
+ """Flatten a Claude message content (str or block list) to training text."""
9
+ if isinstance(content, str):
10
+ out = content
11
+ else:
12
+ parts = []
13
+ for b in content or []:
14
+ t = b.get("type")
15
+ if t == "text":
16
+ parts.append(b["text"])
17
+ elif t == "tool_use":
18
+ parts.append(f'<tool name="{b["name"]}">{json.dumps(b.get("input", {}))}</tool>')
19
+ elif t == "tool_result":
20
+ c = b.get("content")
21
+ c = "".join(x.get("text", "") for x in c) if isinstance(c, list) else str(c or "")
22
+ parts.append(f"<tool_result>{c[:config.RESULT_CAP]}</tool_result>")
23
+ out = "\n".join(p for p in parts if p.strip())
24
+ out = re.sub(r"<system-reminder>.*?</system-reminder>", "", out, flags=re.S)
25
+ out = re.sub(r"<local-command-caveat>.*?</local-command-caveat>", "", out, flags=re.S)
26
+ return (out[:cap] if cap else out).strip()
27
+
28
+
29
+ def parse_session(path):
30
+ """One transcript file -> list of alternating {role, content} messages."""
31
+ msgs = []
32
+ for line in open(path, errors="replace"):
33
+ try:
34
+ rec = json.loads(line)
35
+ except json.JSONDecodeError:
36
+ continue
37
+ if rec.get("type") not in ("user", "assistant") or rec.get("isSidechain") or rec.get("isMeta"):
38
+ continue
39
+ m = rec.get("message") or {}
40
+ role = m.get("role")
41
+ txt = _text(m.get("content"))
42
+ if not role or not txt:
43
+ continue
44
+ if msgs and msgs[-1]["role"] == role:
45
+ msgs[-1]["content"] += "\n" + txt
46
+ else:
47
+ msgs.append({"role": role, "content": txt})
48
+ return msgs
49
+
50
+
51
+ def collect(project=None):
52
+ since = float(open(config.TRACE_MARK).read()) if os.path.exists(config.TRACE_MARK) else 0.0
53
+ slug = "-" + os.path.abspath(project or os.getcwd()).strip("/").replace("/", "-").replace(".", "-")
54
+ dirs = [os.path.join(config.PROJECTS, slug)] if os.path.isdir(os.path.join(config.PROJECTS, slug)) \
55
+ else glob.glob(os.path.join(config.PROJECTS, "*"))
56
+ examples, seen, dupes = [], set(), 0
57
+ for d in dirs:
58
+ for f in glob.glob(os.path.join(d, "*.jsonl")):
59
+ if os.path.getmtime(f) < since:
60
+ continue
61
+ msgs = parse_session(f)
62
+ if len(msgs) < 2:
63
+ continue
64
+ # one example per assistant turn, with preceding context under budget
65
+ for i, m in enumerate(msgs):
66
+ if m["role"] != "assistant":
67
+ continue
68
+ ctx, size = [], 0
69
+ for prev in reversed(msgs[:i]):
70
+ size += len(prev["content"])
71
+ if size > config.CTX_BUDGET:
72
+ break
73
+ ctx.insert(0, prev)
74
+ if not ctx or ctx[0]["role"] != "user":
75
+ continue
76
+ ex = {"messages": [{"role": "system", "content": config.SYSTEM}, *ctx, m]}
77
+ sig = hash(json.dumps(ex["messages"], sort_keys=True))
78
+ if sig in seen:
79
+ dupes += 1
80
+ continue
81
+ seen.add(sig)
82
+ examples.append(ex)
83
+ if not examples:
84
+ hint = " (trace marker active — only sessions after `trace` count; delete ~/.claude/footprint-trace for all history)" if since else ""
85
+ sys.exit(f"no transcripts found under {config.PROJECTS}{hint}")
86
+ random.seed(0)
87
+ random.shuffle(examples)
88
+ n_valid = max(1, len(examples) // 10)
89
+ os.makedirs(config.DATA, exist_ok=True)
90
+ for name, chunk in [("valid", examples[:n_valid]), ("train", examples[n_valid:])]:
91
+ with open(os.path.join(config.DATA, f"{name}.jsonl"), "w") as fh:
92
+ for e in chunk:
93
+ fh.write(json.dumps(e) + "\n")
94
+ print(f"collected {len(examples)} examples ({len(examples)-n_valid} train / {n_valid} valid, "
95
+ f"{dupes} duplicates skipped) -> {config.DATA}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprint-trace",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local SLM trained on your Claude Code sessions. When Claude quota runs out, OpenCode keeps working like Claude.",
5
5
  "bin": {
6
6
  "footprint": "bin/footprint.js"
@@ -11,6 +11,8 @@
11
11
  "files": [
12
12
  "bin",
13
13
  "footprint.py",
14
+ "footprint_core/*.py",
15
+ "footprint_core/backends/*.py",
14
16
  "skills"
15
17
  ],
16
18
  "license": "MIT"
@@ -6,8 +6,10 @@ description: Local SLM that learns from Claude Code transcripts ("footprint") an
6
6
  # Footprint
7
7
 
8
8
  Footprint collects this machine's Claude Code session transcripts, fine-tunes a
9
- small local model (MLX LoRA, Apple Silicon) on them, and can then execute tasks
10
- locally with a tiny bash-tool agent loop — no API needed.
9
+ small local model on them (MLX LoRA on Apple Silicon, torch+PEFT elsewhere),
10
+ and serves it as an OpenAI-compatible API for OpenCode/Cursor/Codex.
11
+ Code layout: `footprint.py` (entry shim) -> `footprint_core/` (cli, config,
12
+ transcripts, service, backends/{mlx,torch}_backend.py).
11
13
 
12
14
  Repo: `/Users/aman/Developer/footprint`. Python: `/Users/aman/Developer/footprint/.venv/bin/python`
13
15
  (if `.venv` missing, run `setup` first with system `python3`).