Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/FOOTPRINT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Resident-service footprint

> FOUNDATIONS §5.1. LISA's `serve` backend is long-lived (observers + Sense +
> island). This documents its cost model + the knobs, and how to measure it on
> your machine — real numbers can only come from your hardware, so this ships a
> harness + a table to fill, not fabricated figures.

## What runs while idle

With nothing granted and no chat, the backend is **event-driven + low-frequency
poll**, not a busy loop:

| source | mechanism | default cadence |
|---|---|---|
| claude-code observer | `fs.watch` on `~/.claude/projects` | event-driven (+400ms debounce) |
| codex observer | `fs.watch` on `~/.codex/sessions` | event-driven (off unless enabled) |
| opencode observer | sqlite poll | 60s (off unless enabled) |
| git observer | `fs.watch` on repo refs | event-driven (off unless `watchRoots` set) |
| ScreenSource (S2) | `osascript` foreground probe | 15s — **but only when `screen` is granted** |
| island web client | poll ping / sessions / consent | 30s / 60s / 30s |
| island re-render | relative-time refresh | 15s |
| screen-advisor | full screenshot → model | off by default; ≥10min when on |

Default-off is the rule: a fresh install observes only claude-code (fs.watch, ~0
CPU at rest) — no screenshots, no audio, no model calls until you ask.

## The cost knobs

The main dials, smallest-cost-first:

- **Sense `screen` grant** — off by default. When on, `ScreenSource` runs one
`osascript` every **15s** (`DEFAULT_INTERVAL_MS` in `src/sense/screen.ts`). It
captures app names only (no screenshot), so cost is one cheap subprocess/tick.
- **screen-advisor** — the expensive one (a full screenshot sent to the model).
Off by default; interval ≥10min when enabled. This is the model-call cost, not
CPU.
- **opencode `pollMs`** — 60s default; raise it if you don't watch OpenCode.
- **enabled observers** — each enabled agent observer adds an `fs.watch`. Disable
the ones you don't use in `~/.lisa/agents.json`.

`cwdGitBranch` (codex/opencode O-D1) caches per cwd for 30s, so branch derivation
doesn't spawn git on every record.

## Measuring it

```sh
lisa serve --web & # start the backend
npx tsx scripts/footprint.ts # samples the serve pid for 60s
# or: npx tsx scripts/footprint.ts --pid <pid> --seconds 120 --interval 5
```

For a true **idle baseline**: leave the machine alone during the window with
only presence/git/agent observation on (no chat, granted sense sources off).

## Acceptance (FOUNDATIONS §5.1)

- [ ] Idle CPU is **negligible** (single-digit % peaks at most from the polls/
fs.watch callbacks; ~0 at true rest).
- [ ] RSS is stable (no growth over a long window — the logs/journals are bounded
+ retention-capped).
- [ ] Granting `screen` adds only a modest, periodic blip (one osascript/15s),
not a sustained load.

## Measured footprint log

Fill in from `scripts/footprint.ts` on your machine + config.

| date | machine | config | window | CPU avg / peak | RSS avg / peak |
|---|---|---|---|---|---|
| _pending_ | — | idle (claude-code only) | — | — | — |
| _pending_ | — | `screen` granted | — | — | — |
99 changes: 99 additions & 0 deletions scripts/footprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env tsx
/**
* Resident-service footprint benchmark (FOUNDATIONS §5.1).
*
* Real energy/CPU numbers can only come from sustained measurement on YOUR
* machine, so this is a harness, not fabricated figures: it samples a running
* `lisa serve` process's CPU% + RSS over a window and reports avg/peak. Pair it
* with docs/FOOTPRINT.md (the cost model + the tunable knobs).
*
* Usage:
* lisa serve --web & # start the backend
* npx tsx scripts/footprint.ts # auto-finds the serve process
* npx tsx scripts/footprint.ts --pid 1234 --seconds 120 --interval 5
*
* Tip: for a true IDLE baseline, leave the machine alone during the window with
* only presence/git/agent observation on (no chat, no granted sense sources).
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const pexec = promisify(execFile);

function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}

async function findServePid(): Promise<number | undefined> {
try {
const { stdout } = await pexec("pgrep", ["-f", "cli.js serve"]);
const pid = stdout.split("\n").map((s) => parseInt(s.trim(), 10)).find((n) => Number.isInteger(n) && n !== process.pid);
return pid;
} catch {
return undefined;
}
}

/** One sample of a pid's %cpu and RSS (KB) via ps. null if the process is gone. */
async function sample(pid: number): Promise<{ cpu: number; rssKb: number } | null> {
try {
const { stdout } = await pexec("ps", ["-o", "%cpu=,rss=", "-p", String(pid)]);
const m = stdout.trim().split(/\s+/);
const cpu = parseFloat(m[0] ?? "");
const rssKb = parseInt(m[1] ?? "", 10);
if (!Number.isFinite(cpu) || !Number.isFinite(rssKb)) return null;
return { cpu, rssKb };
} catch {
return null;
}
}

function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}

async function main(): Promise<void> {
const seconds = Math.max(5, parseInt(arg("seconds") ?? "60", 10));
const interval = Math.max(1, parseInt(arg("interval") ?? "5", 10));
let pid = arg("pid") ? parseInt(arg("pid")!, 10) : await findServePid();

if (!pid || !Number.isInteger(pid)) {
console.error("No `lisa serve` process found. Start one (`lisa serve --web &`) or pass --pid <pid>.");
process.exit(1);
}
console.log(`Sampling pid ${pid} every ${interval}s for ${seconds}s…`);
console.log("(for an idle baseline: don't touch the machine, keep granted sense sources off)\n");

const cpus: number[] = [];
const rss: number[] = [];
const ticks = Math.floor(seconds / interval);
for (let i = 0; i < ticks; i++) {
const s = await sample(pid);
if (!s) {
console.error(`pid ${pid} is gone — stopping.`);
break;
}
cpus.push(s.cpu);
rss.push(s.rssKb / 1024); // MB
process.stdout.write(` t+${i * interval}s cpu=${s.cpu.toFixed(1)}% rss=${(s.rssKb / 1024).toFixed(0)}MB\n`);
if (i < ticks - 1) await sleep(interval * 1000);
}

if (cpus.length === 0) {
console.error("No samples collected.");
process.exit(1);
}
const avg = (a: number[]) => a.reduce((x, y) => x + y, 0) / a.length;
const peak = (a: number[]) => Math.max(...a);
console.log("\n── footprint ──");
console.log(` CPU: avg ${avg(cpus).toFixed(1)}% peak ${peak(cpus).toFixed(1)}%`);
console.log(` RSS: avg ${avg(rss).toFixed(0)}MB peak ${peak(rss).toFixed(0)}MB`);
console.log(` samples: ${cpus.length} over ~${cpus.length * interval}s`);
console.log("\nRecord this in docs/FOOTPRINT.md against your machine + config.");
}

main().catch((e) => {
console.error("footprint failed:", e);
process.exit(1);
});
Loading