@octocodeai/freellama-mcp-server 0.5.0 → 0.6.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.
@@ -1,487 +0,0 @@
1
- """Read-only execution of model-written shell commands.
2
-
3
- `delegate_research` runs the bash adapter against the caller's *real* workspace, so a command is
4
- untrusted input with the user's privileges. A regex denylist in front of `bash -c` let
5
- `echo x > README.md`, `rm -rf ./x`, `git checkout -- .` and `cat $(printf "\\057etc\\057hostname")`
6
- through, and the child inherited every secret in the MCP server's environment.
7
-
8
- Confinement is layered so that no single check carries the whole guarantee:
9
-
10
- 1. **Command validation** (always): every pipeline segment must start with an allowlisted
11
- read-only tool, tool flags that write or execute (``find -delete``, ``sed -i``, ``rg --pre`` ...)
12
- are refused, and shell features that hide a command or a path from validation (command/process
13
- substitution, parameter expansion, ANSI-C quoting, subshells, loops) are refused.
14
- 2. **Restricted bash** (always): ``bash -r`` refuses output redirection, ``cd``, command names with
15
- a slash, and changing ``PATH``; ``PATH`` holds only symlinks to the allowlisted tools, so
16
- ``python3``, ``rm`` or ``curl`` do not exist for the command.
17
- 3. **Scrubbed environment** (always): only locale variables survive; ``HOME`` is the workspace.
18
- 4. **OS sandbox** (when available): ``bwrap`` on Linux mounts only system directories and the
19
- workspace read-only with no network; ``sandbox-exec`` on macOS denies writes, network, and reads
20
- under ``/Users`` and ``/Volumes`` outside the workspace. Each is probed once and skipped if the
21
- host refuses it (for example a container without user namespaces).
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import glob
27
- import os
28
- import re
29
- import shlex
30
- import shutil
31
- import subprocess
32
- import tempfile
33
- from dataclasses import dataclass, field
34
- from pathlib import Path
35
-
36
- # Tools that only read. `awk` is absent on purpose: `system()`, `print >` and `cmd | getline` make
37
- # any awk program a general executor, and mawk/BSD awk have no sandbox flag.
38
- READ_ONLY_TOOLS = (
39
- "ls", "find", "grep", "egrep", "fgrep", "rg", "cat", "head", "tail", "wc", "sort", "uniq",
40
- "cut", "tr", "nl", "file", "stat", "du", "tree", "basename", "dirname", "realpath", "readlink",
41
- "sed", "jq", "diff", "cmp", "column", "xargs", "git", "tac", "fold", "paste", "comm", "od",
42
- "strings", "seq", "expr",
43
- )
44
- SAFE_BUILTINS = frozenset({"echo", "printf", "true", "false", "test", "[", "pwd"})
45
- READ_ONLY_GIT = frozenset({
46
- "log", "show", "grep", "ls-files", "ls-tree", "diff", "blame", "status", "rev-parse",
47
- "cat-file", "shortlog", "describe", "rev-list", "name-rev", "annotate",
48
- })
49
-
50
- # Per-tool flags that write a file or run another program.
51
- _FORBIDDEN_FLAGS: dict[str, tuple[str, ...]] = {
52
- "find": ("-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"),
53
- "sort": ("-o", "--output", "--compress-program", "--files0-from"),
54
- "wc": ("--files0-from",),
55
- "grep": ("-f", "--file"),
56
- "egrep": ("-f", "--file"),
57
- "fgrep": ("-f", "--file"),
58
- "rg": ("--pre", "--pre-glob", "--search-zip", "-z", "-f", "--file"),
59
- "tree": ("-o",),
60
- "file": ("-C", "--compile", "-f", "--files-from"),
61
- "jq": ("-f", "--from-file", "-L", "--library-path"),
62
- "git": ("-C", "-c", "--config-env", "--exec-path", "--output", "-o", "--ext-diff", "--textconv", "--open-files-in-pager", "-O"),
63
- "diff": ("--to-file",),
64
- }
65
- _SED_IN_PLACE = re.compile(r"^(?:-[a-zA-Z]*i|--in-place)")
66
-
67
- # Shell syntax that would hide a command or a path from validation.
68
- _HIDDEN_EXPANSION = re.compile(r"\$\(|`|<\(|>\(|\$'|\$\{|\$[A-Za-z_0-9@*#?!$-]")
69
- _ALLOWED_STDERR_REDIRECT = re.compile(r"(?<![\w>&])2>\s*/dev/null|(?<![\w>&])2>&1")
70
-
71
- _HOME_ESCAPE = re.compile(r"(?:^|[\s=\"'])(?:~|\$HOME\b|\$\{HOME\})")
72
- _ABS_PATH = re.compile(r"(?:^|[\s=\"'(:,])(/[^\s\"';|&)]*)")
73
- _DOTDOT_PATH = re.compile(r"(?:^|[\s=\"'/])\.\.(?:/|[\s\"']|$)")
74
- _FS_ABS_PREFIX = re.compile(
75
- r"^/(?:etc|usr|home|Users|var|tmp|private|opt|root|System|Library|bin|sbin|dev|proc|sys|"
76
- r"Applications|Volumes|mnt|media|srv|run|nix|snap)(?:/|$)"
77
- )
78
- _GLOB_CHARS = re.compile(r"[*?\[]")
79
-
80
-
81
- def _outside_single_quotes(command: str) -> str:
82
- """The command with single-quoted spans blanked, since bash expands nothing inside them."""
83
- out: list[str] = []
84
- quoted = False
85
- escaped = False
86
- for char in command:
87
- if quoted:
88
- quoted = char != "'"
89
- out.append(" ")
90
- continue
91
- if escaped:
92
- escaped = False
93
- out.append(" ")
94
- continue
95
- if char == "\\":
96
- escaped = True
97
- out.append(" ")
98
- continue
99
- if char == "'":
100
- quoted = True
101
- # Keep the quote after an unquoted `$` so `$'\057etc'` (ANSI-C quoting) stays visible.
102
- out.append("'" if out and out[-1] == "$" else " ")
103
- continue
104
- out.append(char)
105
- return "".join(out)
106
-
107
-
108
- def assert_command_confined(root: Path, command_text: str) -> None:
109
- """Reject command text that names a path outside the workspace."""
110
- if _HOME_ESCAPE.search(command_text):
111
- raise ValueError("command blocked: home-directory path is outside the workspace")
112
- if _DOTDOT_PATH.search(command_text):
113
- raise ValueError("command blocked: '..' walks outside the workspace")
114
- root = root.resolve()
115
- for match in _ABS_PATH.finditer(command_text):
116
- raw = match.group(1)
117
- if _GLOB_CHARS.search(raw):
118
- # `/et?/hostname` exists only after expansion; judge every match, and treat a
119
- # pattern that matches nothing inside the workspace as an escape.
120
- matches = [Path(item) for item in glob.glob(raw)]
121
- if not matches and not raw.startswith(str(root)):
122
- raise ValueError(f"command blocked: path pattern escapes workspace: {raw}")
123
- candidates = matches
124
- else:
125
- candidate = Path(raw)
126
- if _FS_ABS_PREFIX.match(raw) is None and not candidate.exists():
127
- continue # e.g. a URL path in a grep pattern, not a filesystem path
128
- candidates = [candidate]
129
- for candidate in candidates:
130
- resolved = candidate.resolve()
131
- if resolved != root and root not in resolved.parents:
132
- raise ValueError(f"command blocked: path escapes workspace: {raw}")
133
-
134
-
135
- def _segments(command_text: str) -> list[list[str]]:
136
- lexer = shlex.shlex(command_text, posix=True, punctuation_chars=";&|()<>")
137
- lexer.whitespace_split = True
138
- lexer.commenters = ""
139
- segments: list[list[str]] = [[]]
140
- for token in lexer:
141
- if token in {"|", "||", "&&", ";"}:
142
- segments.append([])
143
- elif token and set(token) <= set(";&|()<>"):
144
- if token == "<":
145
- segments[-1].append(token)
146
- continue
147
- raise ValueError(
148
- f"command blocked: shell operator {token!r} is not allowed (read-only research: "
149
- "no output redirection, background jobs or subshells)"
150
- )
151
- else:
152
- segments[-1].append(token)
153
- return [segment for segment in segments if segment]
154
-
155
-
156
- def _check_tool(words: list[str]) -> None:
157
- if not words:
158
- return
159
- name = words[0]
160
- args = words[1:]
161
- if "/" in name:
162
- raise ValueError(f"command blocked: run tools by name, not path: {name}")
163
- if name in SAFE_BUILTINS:
164
- return
165
- if name not in READ_ONLY_TOOLS:
166
- raise ValueError(
167
- f"command blocked: {name!r} is not a read-only research tool "
168
- f"(allowed: {', '.join(READ_ONLY_TOOLS)})"
169
- )
170
- for flag in _FORBIDDEN_FLAGS.get(name, ()):
171
- if any(
172
- arg == flag or arg.startswith(flag + "=")
173
- or (len(flag) == 2 and flag.startswith("-") and not arg.startswith("--")
174
- and arg.startswith("-") and flag[1] in arg[1:])
175
- for arg in args
176
- ):
177
- raise ValueError(f"command blocked: `{name} {flag}` loads external arguments, writes or executes")
178
- if name == "sed":
179
- for arg in args:
180
- if _SED_IN_PLACE.match(arg):
181
- raise ValueError("command blocked: sed -i edits files")
182
- scripts = _sed_programs(args)
183
- for script in scripts:
184
- _check_sed_program(script)
185
- if name == "git":
186
- subcommand = next((arg for arg in args if not arg.startswith("-")), None)
187
- if subcommand not in READ_ONLY_GIT:
188
- raise ValueError(
189
- f"command blocked: `git {subcommand}` is not read-only "
190
- f"(allowed: {', '.join(sorted(READ_ONLY_GIT))})"
191
- )
192
- if name == "xargs":
193
- if any(arg == "--arg-file" or arg.startswith("--arg-file=")
194
- or (arg.startswith("-a") and not arg.startswith("--")) for arg in args):
195
- raise ValueError("command blocked: xargs argument files cannot be confined")
196
- target = _xargs_command(args)
197
- if target[0] not in {"echo", "printf", "true", "false"}:
198
- raise ValueError("command blocked: xargs filesystem arguments cannot be confined")
199
- _check_tool(target)
200
-
201
-
202
- def _check_sed_program(program: str) -> None:
203
- """Accept the bounded read-only sed grammar, including addressed substitutions."""
204
- index = 0
205
-
206
- def delimited(start: int) -> int:
207
- delimiter = program[start]
208
- cursor = start + 1
209
- while cursor < len(program):
210
- if program[cursor] == "\\":
211
- cursor += 2
212
- elif program[cursor] == delimiter:
213
- return cursor + 1
214
- else:
215
- cursor += 1
216
- raise ValueError("command blocked: unterminated sed expression")
217
-
218
- while index < len(program):
219
- while index < len(program) and (program[index].isspace() or program[index] in ";{}"):
220
- index += 1
221
- if index == len(program):
222
- return
223
- # An address may be numeric, last-line, or a delimited regular expression.
224
- for address in range(2):
225
- if index < len(program) and program[index].isdigit():
226
- while index < len(program) and program[index].isdigit():
227
- index += 1
228
- elif index < len(program) and program[index] == "$":
229
- index += 1
230
- elif index < len(program) and program[index] == "/":
231
- index = delimited(index)
232
- elif index + 1 < len(program) and program[index] == "\\":
233
- index = delimited(index + 1)
234
- else:
235
- break
236
- while index < len(program) and program[index].isspace():
237
- index += 1
238
- if address == 0 and index < len(program) and program[index] == ",":
239
- index += 1
240
- continue
241
- break
242
- while index < len(program) and (program[index].isspace() or program[index] == "!"):
243
- index += 1
244
- if index == len(program):
245
- raise ValueError("command blocked: sed command is missing")
246
- command = program[index]
247
- index += 1
248
- if command == "s" and index < len(program):
249
- index = delimited(index)
250
- # The replacement shares the first delimiter, so include it in the next scan.
251
- index = delimited(index - 1)
252
- flags_start = index
253
- while index < len(program) and program[index] not in ";}\n":
254
- index += 1
255
- flags = program[flags_start:index].strip()
256
- if any(flag not in "gIp0123456789" for flag in flags):
257
- raise ValueError("command blocked: sed substitution flags can read, write or execute")
258
- elif command not in "pPdDqQnNhHgGx={}":
259
- raise ValueError("command blocked: sed command can read, write or execute files")
260
- if command not in "{}" and index < len(program) and not (program[index].isspace() or program[index] in ";{}"):
261
- raise ValueError("command blocked: unsupported sed command argument")
262
-
263
-
264
- def _sed_programs(args: list[str]) -> list[str]:
265
- """Inspect every inline program; loading another file would hide its commands."""
266
- programs: list[str] = []
267
- index = 0
268
- while index < len(args):
269
- arg = args[index]
270
- if arg == "--":
271
- programs.extend(args[index + 1:])
272
- break
273
- if arg == "--file" or arg.startswith("--file="):
274
- raise ValueError("command blocked: sed script files can read, write or execute")
275
- if arg == "--expression":
276
- index += 1
277
- if index >= len(args):
278
- raise ValueError("command blocked: sed expression is missing")
279
- programs.append(args[index])
280
- elif arg.startswith("--expression="):
281
- programs.append(arg.split("=", 1)[1])
282
- elif arg.startswith("-") and not arg.startswith("--"):
283
- # n/E/r are no-argument mode switches. e/f end a short-option cluster and
284
- # consume the remainder (or next argument), so attached programs stay visible.
285
- short = arg[1:]
286
- for offset, option in enumerate(short):
287
- if option == "f":
288
- raise ValueError("command blocked: sed script files can read, write or execute")
289
- if option == "e":
290
- program = short[offset + 1:]
291
- if not program:
292
- index += 1
293
- if index >= len(args):
294
- raise ValueError("command blocked: sed expression is missing")
295
- program = args[index]
296
- programs.append(program)
297
- break
298
- if option not in "nEr":
299
- raise ValueError("command blocked: unsupported sed option")
300
- elif not arg.startswith("--"):
301
- if not programs:
302
- programs.append(arg)
303
- else:
304
- raise ValueError("command blocked: unsupported sed option")
305
- index += 1
306
- return programs
307
-
308
-
309
- def _xargs_command(args: list[str]) -> list[str]:
310
- with_value = {"-n", "-I", "-d", "-P", "-L", "-s", "-E", "-a", "--arg-file", "--delimiter", "--max-args", "--max-procs"}
311
- index = 0
312
- while index < len(args) and args[index].startswith("-"):
313
- index += 2 if args[index] in with_value else 1
314
- command = args[index:]
315
- return command or ["echo"]
316
-
317
-
318
- def validate_command(root: Path, command_text: str) -> str:
319
- """Return the command to run under restricted bash, or raise ValueError naming the refusal."""
320
- if not command_text.strip():
321
- raise ValueError("empty shell command")
322
- if "\n" in command_text.strip():
323
- raise ValueError("command blocked: send one command line per turn")
324
- # stderr is captured together with stdout already, so these two redirects are no-ops that
325
- # models write out of habit; rbash would refuse them as output redirection.
326
- command_text = _ALLOWED_STDERR_REDIRECT.sub(" ", command_text).strip()
327
- unquoted = _outside_single_quotes(command_text)
328
- if _HIDDEN_EXPANSION.search(unquoted):
329
- raise ValueError(
330
- "command blocked: command substitution, process substitution and $-expansion are "
331
- "not allowed; write paths and patterns literally"
332
- )
333
- # Quoted program braces are literal; unquoted brace paths expand before execution.
334
- brace_tokens = shlex.shlex(command_text, posix=False)
335
- brace_tokens.whitespace_split = True
336
- brace_tokens.commenters = ""
337
- for token in brace_tokens:
338
- if token.startswith(("'", '"', "--exclude-dir=")):
339
- continue
340
- if "{" in token or "}" in token:
341
- raise ValueError("command blocked: brace-expanded paths cannot be confined")
342
- for segment in _segments(command_text):
343
- for word in segment[1:]:
344
- if word.startswith("--exclude-dir="):
345
- continue
346
- if word == "<":
347
- continue
348
- if word.startswith("-"):
349
- if "=" not in word:
350
- continue
351
- word = word.split("=", 1)[1]
352
- candidate = root / word
353
- candidates = [Path(match) for match in glob.glob(str(candidate))] if _GLOB_CHARS.search(word) else [candidate]
354
- for candidate in candidates:
355
- if candidate.exists() or candidate.is_symlink():
356
- resolved = candidate.resolve()
357
- if resolved != root.resolve() and root.resolve() not in resolved.parents:
358
- raise ValueError("command blocked: relative path escapes workspace")
359
- tool = segment[0]
360
- follow_flags = {"rg": "L", "grep": "R", "egrep": "R", "fgrep": "R", "find": "L", "tree": "l", "du": "L"}.get(tool)
361
- if follow_flags and any(
362
- arg == "--follow" or arg == "--dereference" or arg == "--dereference-recursive"
363
- or (arg.startswith("-") and not arg.startswith("--") and follow_flags in arg[1:])
364
- for arg in segment[1:]
365
- ):
366
- raise ValueError("command blocked: recursive symlink following cannot be confined")
367
- if "=" in segment[0] and not segment[0].startswith("="):
368
- raise ValueError("command blocked: variable assignment is not allowed")
369
- if segment[0] in {"for", "while", "until", "if", "case", "function", "{", "eval", "exec", "source", "."}:
370
- raise ValueError(f"command blocked: shell construct {segment[0]!r} is not allowed; use one pipeline")
371
- _check_tool([word for word in segment if word != "<"])
372
- assert_command_confined(root, command_text)
373
- return command_text
374
-
375
-
376
- def _probe(argv: list[str], env: dict[str, str]) -> bool:
377
- try:
378
- return subprocess.run(argv, env=env, capture_output=True, timeout=10, check=False).returncode == 0
379
- except (OSError, subprocess.TimeoutExpired):
380
- return False
381
-
382
-
383
- def _macos_profile(workspace: Path, scratch: Path) -> str:
384
- def quote(path: Path) -> str:
385
- return '"' + str(path).replace("\\", "\\\\").replace('"', '\\"') + '"'
386
-
387
- return f"""(version 1)
388
- (allow default)
389
- (deny network*)
390
- (deny file-write*)
391
- (allow file-write* (literal "/dev/null") (literal "/dev/tty") (literal "/dev/dtracehelper") (subpath {quote(scratch)}))
392
- (deny file-read-data (subpath "/Users") (subpath "/Volumes") (subpath "/private/var/root"))
393
- (allow file-read-data (subpath {quote(workspace)}) (subpath {quote(scratch)}))
394
- """
395
-
396
-
397
- @dataclass
398
- class ReadOnlyShell:
399
- """A reusable read-only shell rooted at `workspace`. Use as a context manager."""
400
-
401
- workspace: Path
402
- kind: str = "restricted-bash"
403
- _scratch: tempfile.TemporaryDirectory[str] | None = field(default=None, repr=False)
404
- _prefix: list[str] = field(default_factory=list, repr=False)
405
- _env: dict[str, str] = field(default_factory=dict, repr=False)
406
- _bash: str = "/bin/bash"
407
-
408
- def __enter__(self) -> "ReadOnlyShell":
409
- self.workspace = self.workspace.resolve()
410
- self._scratch = tempfile.TemporaryDirectory(prefix="freellama-shell-")
411
- scratch = Path(self._scratch.name)
412
- bin_dir = scratch / "bin"
413
- bin_dir.mkdir()
414
- # Resolve tools against the caller's PATH once, before the environment is scrubbed.
415
- for tool in READ_ONLY_TOOLS:
416
- located = shutil.which(tool)
417
- if located:
418
- (bin_dir / tool).symlink_to(os.path.realpath(located))
419
- self._bash = shutil.which("bash") or "/bin/bash"
420
- self._env = {
421
- key: value
422
- for key, value in os.environ.items()
423
- if key in {"LANG", "LC_ALL", "LC_CTYPE", "TZ"}
424
- }
425
- self._env.update({
426
- "PATH": str(bin_dir),
427
- "HOME": str(self.workspace),
428
- "TMPDIR": str(scratch),
429
- "TERM": "dumb",
430
- "PAGER": "cat",
431
- "GIT_PAGER": "cat",
432
- "GIT_CONFIG_NOSYSTEM": "1",
433
- "GIT_CONFIG_GLOBAL": os.devnull,
434
- "GIT_OPTIONAL_LOCKS": "0", # `git status` otherwise rewrites the index
435
- "GIT_TERMINAL_PROMPT": "0",
436
- })
437
- self._prefix = self._os_sandbox(scratch, bin_dir)
438
- return self
439
-
440
- def __exit__(self, *_exc: object) -> None:
441
- if self._scratch is not None:
442
- self._scratch.cleanup()
443
- self._scratch = None
444
-
445
- def _os_sandbox(self, scratch: Path, bin_dir: Path) -> list[str]:
446
- if os.environ.get("FREELLAMA_AGENT_OS_SANDBOX", "auto") == "off":
447
- return []
448
- sandbox_exec = shutil.which("sandbox-exec")
449
- if sandbox_exec:
450
- profile = scratch / "profile.sb"
451
- profile.write_text(_macos_profile(self.workspace, scratch), encoding="utf-8")
452
- prefix = [sandbox_exec, "-f", str(profile)]
453
- if _probe([*prefix, self._bash, "-c", "true"], self._env):
454
- self.kind = "sandbox-exec+restricted-bash"
455
- return prefix
456
- bwrap = shutil.which("bwrap")
457
- if bwrap:
458
- prefix = [bwrap, "--unshare-all", "--die-with-parent", "--new-session",
459
- "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]
460
- for system_dir in ("/usr", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc/alternatives",
461
- "/etc/ld.so.cache", "/etc/localtime", "/nix/store"):
462
- prefix += ["--ro-bind-try", system_dir, system_dir]
463
- prefix += ["--ro-bind", str(self.workspace), str(self.workspace),
464
- "--ro-bind", str(bin_dir), str(bin_dir),
465
- "--bind", str(scratch), str(scratch),
466
- "--chdir", str(self.workspace), "--"]
467
- if _probe([*prefix, self._bash, "-c", "true"], self._env):
468
- self.kind = "bwrap+restricted-bash"
469
- return prefix
470
- return []
471
-
472
- def run(self, command_text: str, timeout_seconds: float) -> str:
473
- command = validate_command(self.workspace, command_text)
474
- result = subprocess.run(
475
- [*self._prefix, self._bash, "--restricted", "--noprofile", "--norc", "-c", command],
476
- cwd=self.workspace,
477
- env=self._env,
478
- text=True,
479
- capture_output=True,
480
- timeout=timeout_seconds,
481
- check=False,
482
- stdin=subprocess.DEVNULL,
483
- )
484
- output = (result.stdout or "") + (result.stderr or "")
485
- if not output.strip():
486
- output = f"(no output, exit code {result.returncode})"
487
- return output
package/docs/ECONOMICS.md DELETED
@@ -1,110 +0,0 @@
1
- # Token economics
2
-
3
- Offloading can keep source material and intermediate tool output out of the calling agent's
4
- context. FreeLlama returns compact results while the local model performs the work. This shifts
5
- computation to local hardware; it does not eliminate model tokens, power use, or waiting time.
6
-
7
- The per-call rows come from one machine (M4 Pro, 48 GB unified memory). Values marked with `~`
8
- and the session projection are estimates derived from those calls.
9
-
10
- ## Per-call, measured
11
-
12
- | Work | Source tokens | Returned tokens | Preserved |
13
- |---|---:|---:|---:|
14
- | 6 grounded code questions | 59,208 | 1,742 | **97.1%** |
15
- | 4 text embeddings (307 ms) | 17,600 | ~200 | **98.9%** |
16
- | 1 image, OCR, byte-exact | 1,970 | 37 | **98.1%** |
17
- | Historical tool-schema surface, per turn | <3.2k | 0 | **100%** |
18
-
19
- ## MCP-envelope audit (2026-09-03)
20
-
21
- The earlier table describes a historical workload on an M4 Pro. This dated audit measured the
22
- eight-tool MCP build available on September 3, 2026. JSON tokenization varies by client and model, so it records
23
- bytes first and gives `characters / 4` only as a deliberately rough token estimate.
24
-
25
- | Audited result | Canonical structured chars | Compact text chars | Duplicate chars avoided | Rough duplicate-token estimate |
26
- |---|---:|---:|---:|---:|
27
- | `doctor {view:"summary"}` | 3,099 | 35 | 3,064 | ~766 |
28
- | `doctor {view:"scheduler"}` | 3,933 | 35 | 3,898 | ~975 |
29
- | `doctor {view:"config"}` | 7,373 | 86 | 7,287 | ~1,822 |
30
- | `doctor {view:"full"}` | 10,836 | 185 | 10,651 | ~2,663 |
31
- | `models {view:"raw", limit:1}` | 447 | 43 | 404 | ~101 |
32
-
33
- These are **not automatically billed-token savings**. They are the second serialized text copy
34
- that FreeLlama does not emit. An MCP host that feeds both `structuredContent` and text into a model
35
- can avoid roughly the listed amount; a host that already keeps only one representation does not.
36
- The canonical structured payload still exists for deterministic clients that need it.
37
-
38
- The integration suite bounds the complete tool schema and initialization instructions using
39
- the same rough token conversion. Read the
40
- [protocol context-budget contract](../packages/mcp/test/integration/protocol.test.ts) for the
41
- enforced ceiling.
42
- This is tool-schema **rent**, not a saving: clients pay it whenever they expose
43
- the entire tool list to a model. Read the bundled documentation resource on demand and do not expose
44
- unneeded tools in a client that supports tool filtering.
45
-
46
- Delegated research has a different economics boundary. It keeps source files and full tool
47
- observations out of the calling agent's active prompt, but it spends local Ollama input/output
48
- tokens in the delegated agent. The historical research rows below remain useful workload evidence;
49
- they are not a claim that the current installed model or host can reproduce those savings. Measure
50
- the same question, source size, model, context policy, and client transcript before using a number
51
- as a production budget.
52
-
53
- ## Scaled to a session
54
-
55
- 20 grounded questions, 200 turns, one embedding index over 322 chunks:
56
-
57
- | Component | Tokens preserved |
58
- |---|---:|
59
- | Research offload | 191,553 |
60
- | Embedding vectors withheld | 354,200 |
61
- | Schema rent avoided, after ~90% prompt-cache discount | 59,740 |
62
- | **Total** | **≈ 605,000 — about 3 × a 200K window** |
63
-
64
- ## Measurement and projection limits
65
-
66
- Schema rent is nominally **597,400** tokens (200 turns × the former eight-tool schemas). Including
67
- the nominal figure puts the headline at **1.14M — nearly double**. This report discounts it by ~90%
68
- because prompt caching makes cached input far cheaper,
69
- and because the rent only accrues at all if a *separate* small model holds the tool schemas. The
70
- first two rows are the uncontestable part: that data physically never reaches the orchestrator.
71
-
72
- **Do not lead with this number.** Providers, caching strategies, context windows, and pricing
73
- change. The durable benefit is context isolation: delegated research returns an answer and
74
- citations in place of its full local transcript, and embedding responses omit vectors by default.
75
- Caller-requested output and exposed tool schemas still enter the client's context. Measure that
76
- client's actual transcript before claiming a token or billing reduction.
77
-
78
- ## The cost side
79
-
80
- | | |
81
- |---|---|
82
- | Grounded question latency | **7–62 s** vs ~1 s for a frontier model that already holds the file |
83
- | Where it goes | **51% prompt re-evaluation**, 42% generation, 7% model load (measured on a 4-turn run) |
84
- | Scaling | turn-dominated: `seconds ≈ 9.8 × tool_calls`, correlation **0.811** over 60 runs |
85
- | Break-even | past ~1k tokens of source the token maths wins; below that, read the file directly |
86
- | Judgment ceiling | ~67% accurate, in the same confident tone as the ~99% case |
87
-
88
- **Correction (measured same day): prefix-cache reuse WORKS on this path.** An earlier revision of
89
- this file claimed the opposite by misreading `prompt_eval_count`, which counts *all* prompt tokens
90
- whether or not they were served from cache. The durations tell the truth:
91
-
92
- | Probe (raw `/api/chat`, 2,462-token prefix) | prompt_eval |
93
- |---|---:|
94
- | turn 1, cold | 18,631 ms |
95
- | turn 2, warm — tiny suffix appended | **281 ms** |
96
- | a *different* conversation interjected | 22,467 ms (its own cold prefill) |
97
- | turn 3 of the original, after the interjection | **285 ms — cache survived** |
98
-
99
- So the per-turn cost is **prefill of NEW tokens plus generation**, not re-reading old context, and
100
- the cache even survives interleaved conversations. The `seconds ≈ 9.8 × tool_calls` relationship
101
- still holds empirically — each turn adds a fresh observation to prefill (~130 tok/s measured) and a
102
- generation (~40 tok/s) — but the mechanism is new-token cost, not context re-evaluation. Fewer,
103
- better-aimed commands remain the lever; "the cache is broken" is not the reason.
104
-
105
- **One consequence worth acting on: context compaction is cache-hostile.** `fit_to_context` rewrites
106
- old observations when the window is nearly full, which changes the byte prefix and invalidates the
107
- cache from that point — the next turn re-prefills everything after the edit. The cheap mitigation is
108
- headroom: `OLLAMA_KV_CACHE_TYPE=q8_0` roughly halves KV memory (flash attention is already auto-on,
109
- measured and documented in `doctor`), which buys double the context for the same memory and makes
110
- compaction — and therefore invalidation — rare.
package/docs/INDEX.md DELETED
@@ -1,21 +0,0 @@
1
- # FreeLlama packaged documentation
2
-
3
- Read only the document relevant to the current operation. Repository `docs/` is the source of truth.
4
-
5
- - [FreeLlama architecture](ARCHITECTURE.md)
6
- - [CLI reference](CLI.md)
7
- - [Run models on CPU and GPU](CPU_GPU_ROUTING.md)
8
- - [Token economics](ECONOMICS.md)
9
- - [Ollama model metadata](MODEL_METADATA.md)
10
- - [Choose local models](MODEL_SELECTION.md)
11
- - [Runtime status and live tuning](MONITORING.md)
12
- - [Ollama sidecar boundary](OLLAMA_SIDECAR.md)
13
- - [Prepare FreeLlama for production](PRODUCTION.md)
14
- - [Product positioning](PRODUCT_POSITIONING.md)
15
- - [FreeLlama documentation](README.md)
16
- - [Scope history and model warming](SCOPES_AND_WARMING.md)
17
- - [Test FreeLlama](TESTING.md)
18
- - [ADR: Resource-aware backend routing](dev/ADR_RESOURCE_AWARE_BACKEND_ROUTING.md)
19
- - [FreeLlama findings and open-source comparison](dev/FINDINGS_AND_POSITIONING.md)
20
- - [Test throughput, CPU/GPU activity, and thermal behavior on macOS](dev/LOCAL_PERFORMANCE_TESTING.md)
21
- - [Ollama and FreeLlama optimization boundary](dev/OLLAMA_SYSTEM_OPTIMIZATION.md)