@looop-games/cli 0.1.34 → 0.1.36

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/CHANGELOG.md CHANGED
@@ -14,6 +14,23 @@ Versions: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
14
14
 
15
15
  ## [Unreleased]
16
16
 
17
+ ## [0.1.36] - 2026-09-04
18
+
19
+ ### Added
20
+
21
+ - **`looop dev` keeps the sessions you play.** As you play, the room hands the dev server each slice of the recording and it lands under your game's `.looop/replays/`. Watch one back by adding `?replay=<id>` to the game's URL — the id is printed to the console when the session starts. Nothing leaves your machine, and nothing is kept when the room is not a dev room.
22
+ - **`?replay=last`** — the newest recording, resolved by the dev server, so watching the session you just played does not start with copying an id.
23
+ - **Old recordings are swept, so a game folder stops growing forever.** When `looop dev` starts it keeps the newest **20 recordings** and up to **200 MB** per game, whichever runs out first — roughly an hour of play for a busy game — and removes the oldest past that, saying on the way up how many went. To protect one, hit **keep** on its row in the toolbox's Sessions panel; a kept recording is never swept. (It still counts toward the caps: keeping means "don't delete this", not "don't charge me for it".)
24
+ - **`looop replay export` — a session you played, as a table you can query.** A recording holds what you PRESSED, not what happened: no position, no speed, no score is in it anywhere. So this replays the session through your own game, reads the world at every tick, and writes it down as Parquet — one row per tick per entity, one column per declared field, plus a second table of every input you sent. Running the game costs a few seconds once; every question you ask afterwards is free, in SQL, with DuckDB or pandas or anything else that reads Parquet. Measured on a real 27-second session: 279,367 rows x 443 columns in 10 seconds, 3.9 MB on disk, and a query answered in 0.02 s. Numbers come back bit-for-bit as the simulation held them. `npx looop replay export --help` for the schema, or `shared/docs/replay-export.md`.
25
+ - **`looop replay export` tells you when the table will not be the session you played — before it spends the time building it.** A recording holds what you pressed, not what happened, so the numbers come from running your current code on old inputs. The export now compares the recording against what you are running and says up front if the engine version or the shape of your game has moved since. While it replays it also reports how many keyframes put playback back onto the recorded state, and the divergence warning no longer claims that everything after a divergence is worthless — a drift is bounded to the stretch before the next keyframe, and saying otherwise had people binning tables that were mostly fine.
26
+ - The dev server answers the toolbox's Sessions panel — listing this game's recordings, deleting one, and pinning one. Only ever on the dev server, and deleting or pinning a recording works only from a page this dev server itself served. `looop dev` listens on every network interface so your phone can play, which means everything else on that wifi can reach it too — on a cafe or coworking network that is not a set of machines you control, and nothing there can now remove one of your sessions.
27
+
28
+ ## [0.1.35] - 2026-09-02
29
+
30
+ ### Added
31
+
32
+ - Reports landing in `.looop/agent-inbox/` are stamped with `engine`, the engine version `looop dev` was serving when the report was made — so a report read later, after an update, still says which engine produced the numbers.
33
+
17
34
  ## [0.1.34] - 2026-09-02
18
35
 
19
36
  ### Fixed
package/bin/looop.mjs CHANGED
@@ -36,6 +36,7 @@ Usage:
36
36
  looop update [--rc <v>] Move this game to the latest engine release (--rc <v> takes a specific pre-release)
37
37
  looop model bake <glb> Re-bake a 3D model's server-side hit data now (normally automatic)
38
38
  looop publish [--slug <s>] Publish this game to play.looop.games (--slug for an A/B copy)
39
+ looop replay export [<id>] Turn a recorded session into a table you can query (Parquet)
39
40
  looop feedback Send reports + replies under notes/feedback/; pull outcomes back in
40
41
  looop login Authenticate this machine as your Looop player account
41
42
  looop logout Forget the stored token
@@ -124,6 +125,23 @@ try {
124
125
  await bakeModel(glb);
125
126
  break;
126
127
  }
128
+ case 'replay': {
129
+ // `replay` is the accessor for a recorded session; `export` is its first
130
+ // verb. Watching one back needs no command at all — it is `?replay=<id>`
131
+ // on the game's URL, or the toolbox's Sessions panel.
132
+ const { REPLAY_HELP, replayExport, parseReplayArgs } = await import('../lib/replay-cmd.mjs');
133
+ // Parsed in the lib so the argv split is under test — a flag's value must
134
+ // never slide into the session slot, and --help must be answered before
135
+ // anything is read as an id.
136
+ const { verb, id, out, help } = parseReplayArgs(rest);
137
+ if (help) {
138
+ console.log(REPLAY_HELP);
139
+ break;
140
+ }
141
+ if (verb !== 'export') throw new Error(`Unknown replay command: ${verb} — try: looop replay export`);
142
+ await replayExport({ stream: id, out });
143
+ break;
144
+ }
127
145
  case 'publish':
128
146
  await publish({ slug: flag('slug') });
129
147
  break;
@@ -69,9 +69,13 @@ const sha = (buf) => createHash('sha256').update(buf).digest('hex');
69
69
 
70
70
  // Where each artifact path lands in the game repo. `skills/**` is the whole
71
71
  // surface today; AGENTS.md is spliced rather than written whole.
72
+ // Targets are manifest keys, not host paths: forward slashes on every platform,
73
+ // because they are recorded in .looop/agent-surface.json, compared with
74
+ // `startsWith(`${dest}/`)`, and reported to the creator. `join(projectDir, dest)`
75
+ // turns one into a host path wherever the filesystem is touched.
72
76
  function target(rel) {
73
77
  if (rel === 'AGENTS.md') return 'AGENTS.md';
74
- if (rel.startsWith('skills/')) return join('.claude', rel);
78
+ if (rel.startsWith('skills/')) return `.claude/${rel}`;
75
79
  return null; // unknown surface entry from a newer engine — ignore, don't guess
76
80
  }
77
81
 
@@ -128,7 +132,7 @@ function contentMatchesRecord(abs, dest, placed) {
128
132
  if (!entries.length) return true; // an empty folder holds nothing to lose
129
133
  return entries.every((e) => {
130
134
  const file = join(e.parentPath, e.name);
131
- const rel = join(dest, relative(abs, file));
135
+ const rel = `${dest}/${relative(abs, file).split('\\').join('/')}`;
132
136
  return placed[rel] !== undefined && sha(readFileSync(file)) === placed[rel];
133
137
  });
134
138
  }
package/lib/dev.mjs CHANGED
@@ -14,6 +14,7 @@ import { join, dirname, basename } from 'node:path';
14
14
  import { findProject } from './project.mjs';
15
15
  import { ensureEngine } from './engine.mjs';
16
16
  import { createStaticServer } from './static-server.mjs';
17
+ import { pruneOnStart } from './replay-store.mjs';
17
18
  import { createLlmShim, DEFAULT_API_BASE } from './llm-shim.mjs';
18
19
  import { getToken, getApiBase } from './config.mjs';
19
20
  import { resolvePorts, portInUse, killPort, lanIp, isRestricted, nextBrowserSafe } from './ports.mjs';
@@ -62,6 +63,11 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
62
63
  const { ensureBaked } = await import('./model-bake.mjs');
63
64
  await ensureBaked({ dir: project.dir, log });
64
65
 
66
+ // Recordings accumulate on the creator's own disk — every session played
67
+ // under `looop dev` writes one — so the oldest go here, before anything can
68
+ // be watching one. The caps and the keep marker live in replay-store.mjs.
69
+ pruneOnStart({ projectDir: project.dir, log });
70
+
65
71
  // Which ports, and may we take them? An explicit --port is obeyed as given
66
72
  // (takeover included). With no flag we step around anyone else's stack —
67
73
  // another lane, another game, an unrelated app — and only ever reclaim our
@@ -146,6 +152,7 @@ export async function dev({ cwd = process.cwd(), port, noMp = process.env.NO_MP
146
152
  slug: project.slug,
147
153
  projectDir: project.dir, // answers /__looop/whoami — see ports.mjs
148
154
  log, // agent-inbox report lines ride the dev logger
155
+ engineVersion: engine.version ?? null, // stamped on agent-inbox reports
149
156
  mounts: [
150
157
  { url: `/games/${project.slug}/`, dir: project.dir },
151
158
  { url: '/shared/', dir: generatedOverridesDir },
@@ -0,0 +1,155 @@
1
+ // `looop replay export` — the recorded session, on disk as a table.
2
+ //
3
+ // This is the CLI's half: find the game, resolve the engine it is pinned to,
4
+ // hand both to the exporter, and then say enough about what came out that the
5
+ // next step is obvious. The table's shape and every decision about it live in
6
+ // replay-export.mjs; nothing here knows what a column is.
7
+ import { join, relative, resolve } from 'node:path';
8
+ import { statSync } from 'node:fs';
9
+ import { findProject } from './project.mjs';
10
+ import { streamDir } from './replay-store.mjs';
11
+ import { ensureEngine } from './engine.mjs';
12
+ import { exportSession } from './replay-export.mjs';
13
+
14
+ export const EXPORTS_DIR = ['.looop', 'exports'];
15
+
16
+ export const REPLAY_HELP = `looop replay — work with the sessions this game has recorded
17
+
18
+ Usage:
19
+ looop replay export [<session>] [--out <dir>]
20
+
21
+ <session> Which recording. Defaults to the most recent one.
22
+ --out Where the tables go. Defaults to .looop/exports/
23
+
24
+ Writes two Parquet tables:
25
+
26
+ <session>.state.parquet one row per tick per entity, every declared field
27
+ a column (def/id/owner/tick lead)
28
+ <session>.inputs.parquet one row per input the player sent
29
+
30
+ A recording holds what was PRESSED, not what happened — so the export replays
31
+ the session through your game to work out where everything was, then writes it
32
+ down. That costs a few seconds once; every question you ask of the table
33
+ afterwards is free.
34
+
35
+ duckdb -c "SELECT def, count(*) FROM '.looop/exports/<session>.state.parquet' GROUP BY def"
36
+ `;
37
+
38
+ /**
39
+ * Split `looop replay …`'s arguments into the verb, the session, and the flags.
40
+ *
41
+ * A flag's VALUE is a positional-looking word, so dropping only the tokens that
42
+ * start with `--` leaves `tables` from `--out tables` sitting in the session
43
+ * slot — and the export then reports it cannot find a recording the creator
44
+ * never named. The same trap is why `--help` has to be answered before any of
45
+ * this is used as an id.
46
+ */
47
+ export function parseReplayArgs(argv = []) {
48
+ const flag = (name) => {
49
+ const i = argv.indexOf(`--${name}`);
50
+ return i === -1 ? undefined : argv[i + 1];
51
+ };
52
+ const positional = argv.filter((a, i) => !a.startsWith('-') && !argv[i - 1]?.startsWith('--'));
53
+ const [verb, id] = positional;
54
+ return {
55
+ verb,
56
+ id,
57
+ out: flag('out'),
58
+ help: !verb || verb === 'help' || argv.includes('--help') || argv.includes('-h'),
59
+ };
60
+ }
61
+
62
+ const mb = (file) => `${(statSync(file).size / 1024 / 1024).toFixed(1)} MB`;
63
+
64
+ /**
65
+ * @param {object} opts
66
+ * @param {string} [opts.cwd]
67
+ * @param {string} [opts.stream] - which recording; defaults to the most recent
68
+ * @param {string} [opts.out] - where the tables go
69
+ * @param {(msg: string) => void} [opts.log]
70
+ * @param {(dir: string, o: object) => Promise<object>} [opts.ensureEngineImpl]
71
+ * @param {(o: object) => Promise<object>} [opts.exportImpl]
72
+ */
73
+ export async function replayExport({
74
+ cwd = process.cwd(), stream, out, log = console.log,
75
+ ensureEngineImpl = ensureEngine, exportImpl = exportSession,
76
+ } = {}) {
77
+ const project = findProject(cwd);
78
+ // The id is about to become a path to read from AND a filename to write. Every
79
+ // other door into the store runs one through `streamDir`, which refuses rather
80
+ // than sanitizes; this one is no different for being typed by hand.
81
+ if (stream) streamDir(project.dir, stream);
82
+ const outDir = out ? resolve(project.dir, out) : join(project.dir, ...EXPORTS_DIR);
83
+ // The tables are tens of megabytes and this creates directories to hold them.
84
+ // A path that climbs out of the game folder is a typo, not an intention.
85
+ if (outDir !== project.dir && !outDir.startsWith(`${resolve(project.dir)}/`)) {
86
+ throw new Error(`--out must be inside the game folder, and ${out} is not`);
87
+ }
88
+ const engine = await ensureEngineImpl(project.dir, { log });
89
+
90
+ const started = Date.now();
91
+ const res = await exportImpl({
92
+ dir: project.dir, stream, outDir, sharedDir: engine.sharedDir,
93
+ engineVersion: engine.version, log,
94
+ });
95
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
96
+
97
+ const rel = (f) => relative(project.dir, f);
98
+ log(`exported ${res.stream} in ${secs}s`);
99
+ log(` ${rel(res.stateFile)} ${res.rows.toLocaleString()} rows x ${res.columns} columns (${mb(res.stateFile)})`);
100
+ log(` ${rel(res.inputFile)} ${res.inputs.toLocaleString()} inputs (${mb(res.inputFile)})`);
101
+ // Three different ways the table can describe a world that is not the one
102
+ // that was played. None of them is buried: an export that looks clean and is
103
+ // not is worse than one that failed, because the answers it gives are wrong
104
+ // and confident.
105
+ if (res.divergences) {
106
+ // What bounds the damage is the keyframe: the stream carries the room's
107
+ // complete true state roughly every 30 s, and playback restores it, so a
108
+ // drift owns one keyframe interval rather than everything downstream.
109
+ // Saying otherwise sends a reader to bin a table that was mostly fine.
110
+ //
111
+ // Only a re-anchor AFTER the last divergence says that last drift was
112
+ // undone; earlier ones belong to earlier drifts. And a re-anchor is recorded
113
+ // only when the restore actually changed the world, so zero of them does not
114
+ // mean nothing corrected it — a drift that came right again on its own also
115
+ // records none. Claiming "the drift ran to the end of the session" from a
116
+ // zero count states something this code cannot know.
117
+ const corrected = res.lastReanchorTick != null
118
+ && res.lastDivergenceTick != null
119
+ && res.lastReanchorTick > res.lastDivergenceTick;
120
+ const putBack = corrected
121
+ ? ` Playback was put back onto the recorded state at tick ${res.lastReanchorTick}, after the last of them.`
122
+ : ' Nothing is recorded as putting it back after the last one, so treat the rows from there to the end as this run\'s.';
123
+ log(` ⚠ the replay parted from the recording ${res.divergences} time(s) — from each of those `
124
+ + 'ticks until the next keyframe, the rows are this run\'s world rather than the recorded one.'
125
+ + putBack
126
+ + ' The usual cause is that the game changed since the session was recorded; a replay runs '
127
+ + 'the code as it is now.');
128
+ }
129
+ if (res.gaps) {
130
+ log(` ⚠ the recording is missing ${res.gaps} segment(s) — the inputs in those gaps were never `
131
+ + 'stored, so the world after each one is not the session that was played');
132
+ }
133
+ if (res.unsupported) {
134
+ log(` ⚠ ${res.unsupported} recorded event(s) cannot be reproduced by a replay (a live tune, say) `
135
+ + '— the world after each one is not the session that was played');
136
+ }
137
+ // Repeated from before the replay. That is the right place to say it first —
138
+ // the answer is known and the replay is the slow part — but a big export puts
139
+ // screens of output in between, so the line deciding whether to trust the
140
+ // table would be the one line scrolled off. Costs nothing; both values are
141
+ // already on the result.
142
+ if (res.recordedEngine && res.runningEngine && res.recordedEngine !== res.runningEngine) {
143
+ log(` ⚠ recorded by engine ${res.recordedEngine}, replayed on ${res.runningEngine}`);
144
+ }
145
+ if (res.codeChanged) {
146
+ log(' ⚠ the game has changed since this session was recorded');
147
+ }
148
+ if (res.undoubleable) {
149
+ log(` note: ${res.undoubleable} numeric cell(s) held NaN or ±Infinity, which Parquet has no double `
150
+ + 'for, and read as null — the same as a field the entity kind does not declare');
151
+ }
152
+ log('');
153
+ log(`Ask it something: duckdb -c "SELECT def, count(*) FROM '${rel(res.stateFile)}' GROUP BY def"`);
154
+ return res;
155
+ }