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 +17 -17
- package/bin/footprint.js +6 -3
- package/footprint.py +3 -213
- package/footprint_core/__init__.py +0 -0
- package/footprint_core/backends/__init__.py +9 -0
- package/footprint_core/backends/mlx_backend.py +23 -0
- package/footprint_core/backends/torch_backend.py +93 -0
- package/footprint_core/cli.py +29 -0
- package/footprint_core/config.py +33 -0
- package/footprint_core/service.py +97 -0
- package/footprint_core/transcripts.py +95 -0
- package/package.json +3 -1
- package/skills/footprint/SKILL.md +4 -2
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)** | ✅ | ✅
|
|
29
|
-
| **
|
|
30
|
-
| **
|
|
31
|
-
| **
|
|
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
|
-
|
|
38
|
-
Apple
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
102
|
+
**4. Install the always-on server.**
|
|
104
103
|
|
|
105
104
|
```sh
|
|
106
105
|
footprint install
|
|
107
106
|
```
|
|
108
107
|
|
|
109
|
-
This registers
|
|
110
|
-
|
|
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` |
|
|
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
|
|
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** —
|
|
155
|
-
-
|
|
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
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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,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.
|
|
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
|
|
10
|
-
|
|
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`).
|