Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ playwright-report/
# Local embedding model weights downloaded by scripts/embed-smoke.mjs
.embed-smoke-cache/

# Unpacked ONNX generated from the committed gzip packs (see vendor/embed-models/README.md).
vendor/embed-models/**/*.onnx
vendor/embed-models/**/*.onnx_data
vendor/embed-models/**/*.tmp

# External folders connected for in-place reading (separate repos)
.external/

Expand Down
9 changes: 8 additions & 1 deletion docs/user/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Commands run on the machine Stem itself runs on: your own computer normally, or
server if you [moved Stem to one](../running-on-a-server.md) — so they see the programs
installed there, not the ones on the computer you happen to be typing on. On macOS and
Linux they run under `zsh`, or `bash`/`sh` on a machine without it; on Windows under
`cmd.exe`.
Git Bash when it is installed, otherwise `cmd.exe`.

- **Manual**: known-safe and always-allowed commands run; everything else asks first.
- **Assisted**: an AI safety check passes routine commands and asks about uncertain
Expand All @@ -60,6 +60,13 @@ Linux they run under `zsh`, or `bash`/`sh` on a machine without it; on Windows u
On an approval card, **Always allow** saves a command prefix for future turns.
Keep prefixes narrow; `git status` grants less access than `git`.

On Windows, **Windows shell** defaults to Git Bash when `bash.exe` is on disk, and
falls back to Command Prompt if it is not. Stem looks for Git Bash without using
PowerShell. If it is not in a usual place, paste the path to `bash.exe`. Commands
then run in that one shell — quoting and the always-allowed list follow it (`dir`
vs `ls`). Pick Command Prompt yourself if you want cmd.exe even though Git is
installed.

<!-- TODO(screenshot): Command approval card with Allow once, Always allow, and Deny. -->

### Commands on your own computer
Expand Down
51 changes: 37 additions & 14 deletions docs/windows-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,38 @@ node node_modules\electron\install.js

## Shell Stem uses for `run_command`

On Windows, approved commands run as:
On Windows, approved commands run in **Git Bash** when Git for Windows is
installed, and fall back to **Command Prompt** otherwise:

Git Bash (when `bash.exe` is on disk):

`bash.exe --noprofile --norc -c "<command>"`

`--noprofile --norc` skips `.bashrc` / `/etc/profile` (the same idea as cmd `/d`).
Git’s `usr\bin` is prepended to PATH so `ls` / `cat` / `grep` work. The safety
parser then follows **bash** quoting, not cmd’s — `ls` auto-runs, `dir` does not.

Stem looks for `bash.exe` on disk (usual Git for Windows paths, then PATH)
without running PowerShell. If Git is installed somewhere unusual, paste the
path to `bash.exe` under Settings → Chat → Command execution.

Command Prompt fallback (no Git Bash, or you pick it in Settings):

`cmd.exe /d /s /c "<command>"`

- `/d` disables AutoRun (registry hooks that behave like a login profile).
- Stem does **not** load PowerShell’s `profile.ps1` for the default path.
- Stem does **not** load PowerShell’s `profile.ps1` for this path.
- The command is wrapped in quotes and spawned with `windowsVerbatimArguments` so
inner `"` (e.g. PowerShell `-Command "..."`) are not turned into `\"`.

### What auto-runs, and what doesn’t
### What auto-runs, and what doesn’t (cmd.exe)

The safety tiers are the same as on macOS, but the parser follows **cmd.exe**
rules, not zsh’s. That changes which commands can skip the safety check:
The safety tiers are the same as on macOS, but the **cmd.exe** parser is not zsh’s.
That changes which commands can skip the safety check:

- Read-only probes auto-run: `dir`, `type`, `where`, `echo`, `cd`, `git status`
and friends. The POSIX names (`ls`, `cat`, `grep`) are not on the Windows
allowlist — under cmd they are not commands.
and friends. The POSIX names (`ls`, `cat`, `grep`) are not on the cmd allowlist
— under cmd they are not commands.
- `'` is **not** a quote character to cmd, so anything containing one goes to the
safety check rather than auto-running. `cmd` would read `type 'a & whoami'` as
two commands, and Stem will not auto-run something it cannot bound.
Expand Down Expand Up @@ -127,14 +142,22 @@ Or avoid pipes with `(...)` / property access when that is enough
1. `node -v` ≥ 24 and `npm -v` with portable Node on PATH.
2. `npm install` → `npm run preflight` → `npm run dev` opens Stem.
3. Complete onboarding / chat with a provider.
4. Ask Stem to run `echo hello`, `dir`, or `git status` — expect a normal result
(or an approval card), not a spawn/`zsh` error.
4. Ask Stem to run `echo hello`, `ls`, or `git status` — expect a normal result
(or an approval card), not a spawn/`zsh` error. Without Git Bash, `dir` is the
Command Prompt equivalent.
5. Confirm a broken `profile.ps1` did not fire for those default commands.
6. Optional: have Stem run the `-NoProfile` PowerShell one-liner above.
7. Assisted mode: ask for `type 'a & whoami & rem '`. It must show an approval
card, never run — cmd would split that into three commands.
8. Connect a folder read-only, then ask Stem to `type` a file inside it. Expect
7. Assisted mode: ask for `cat 'a & whoami'` (Git Bash) or `type 'a & whoami & rem '`
(cmd). It must show an approval card, never run.
8. Connect a folder read-only, then ask Stem to `cat` / `type` a file inside it. Expect
the read-only refusal, not the file.
9. Check that `%APPDATA%\Stem\` appears and survives a restart.
10. Memory / search: if hybrid embeddings fail, check the main log for
`embed-endpoint` / named-pipe errors (FTS-only fallback is safe but weaker).
10. Memory / search: the default embedder is shipped as gzip parts under
`vendor/embed-models/` and unpacked into `%APPDATA%\Stem\embed-models` on
first launch (no Hugging Face). The reranker still downloads from the Hub
when it can; if that host is blocked, ranking falls back to embeddings
alone. Named-pipe errors in the log (`embed-endpoint`) only affect MCP
hybrid search; FTS-only fallback is safe but weaker.
11. Settings → Chat → Command execution → Windows shell should already be Git Bash
when `bash.exe` was found. Ask Stem to run `ls`. Switch to Command Prompt and
`dir` if you want the cmd parser.
3 changes: 3 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ files:
- dist/**
- build/icon.png # loaded at runtime via app.getAppPath() (server/index.ts appIcon)
- RELEASE_NOTES.md # ditto — the "what's new" popup reads it (workspace/release-notes.ts)
# Optional local ONNX cache (vendor/embed-models/README.md). Empty in git; a
# private fork that copies weights here ships them with the packaged app.
- vendor/embed-models/**
# pdf.js is only used for folder-index text extraction (server/folder-index/pdf.ts),
# which loads legacy/build/pdf.mjs + its fake-worker import pdf.worker.mjs.
# The browser builds, rendering assets (cmaps/fonts/wasm/icc), minified
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"eval:skills": "node scripts/skill-author-eval.mjs",
"eval:skill-retrieval": "node scripts/skill-retrieval-eval.mjs",
"fixtures:skills": "node scripts/skill-fixtures.mjs",
"gen:shortcuts-doc": "node scripts/gen-shortcuts-doc.mjs"
"gen:shortcuts-doc": "node scripts/gen-shortcuts-doc.mjs",
"vendor:embed-models": "node scripts/vendor-embed-models.mjs"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "0.82.0",
Expand Down
111 changes: 111 additions & 0 deletions scripts/vendor-embed-models.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env node
// Pack Stem's downloaded embedding weights into vendor/embed-models/ so a
// clone can load them without Hugging Face. GitHub rejects files over 100 MB
// and warns above 50 MB, so each ONNX is gzipped and split into 45 MB parts.
//
// npm run vendor:embed-models [sourceDir]
//
// Default source is this machine's Stem cache (override with STEM_EMBED_MODELS_DIR).
// Stem unpacks the parts into the app cache on first launch.
import {
copyFileSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
openSync,
readSync,
closeSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';
import { fileURLToPath } from 'node:url';

const PART_BYTES = 45 * 1024 * 1024;
const GZIP_FROM_BYTES = 10 * 1024 * 1024;
const root = fileURLToPath(new URL('..', import.meta.url));
const destRoot = join(root, 'vendor', 'embed-models');

function defaultCache() {
if (process.env.STEM_EMBED_MODELS_DIR) return process.env.STEM_EMBED_MODELS_DIR;
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Stem', 'embed-models');
if (process.platform === 'win32') {
return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Stem', 'embed-models');
}
return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'Stem', 'embed-models');
}

function walkFiles(dir) {
const out = [];
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, ent.name);
if (ent.isDirectory()) out.push(...walkFiles(p));
else if (ent.isFile()) out.push(p);
}
return out;
}

function shouldGzip(file, size) {
const lower = file.toLowerCase();
return lower.endsWith('.onnx') || lower.endsWith('.onnx_data') || size >= GZIP_FROM_BYTES;
}

async function gzipFile(src, destGz) {
mkdirSync(dirname(destGz), { recursive: true });
const tmp = `${destGz}.tmp`;
await pipeline(createReadStream(src), createGzip({ level: 9 }), createWriteStream(tmp));
const size = statSync(tmp).size;
if (size <= PART_BYTES) {
renameSync(tmp, destGz);
console.log(` packed ${relative(destRoot, destGz)} (${(size / 1024 / 1024).toFixed(1)} MB)`);
return;
}
const fd = openSync(tmp, 'r');
let offset = 0;
let i = 0;
const buf = Buffer.alloc(PART_BYTES);
while (offset < size) {
const n = readSync(fd, buf, 0, PART_BYTES, offset);
const part = `${destGz}.${String(i).padStart(2, '0')}`;
writeFileSync(part, buf.subarray(0, n));
console.log(` packed ${relative(destRoot, part)} (${(n / 1024 / 1024).toFixed(1)} MB)`);
offset += n;
i += 1;
}
closeSync(fd);
unlinkSync(tmp);
}

const source = process.argv[2] || defaultCache();
if (!existsSync(source)) {
console.error(`No embedding cache at ${source}\nRun Stem once on a machine that can reach huggingface.co, then retry.`);
process.exit(1);
}

const files = walkFiles(source);
if (!files.length) {
console.error(`${source} has no model files yet.`);
process.exit(1);
}

mkdirSync(destRoot, { recursive: true });
for (const abs of files) {
const rel = relative(source, abs);
const size = statSync(abs).size;
if (shouldGzip(abs, size)) {
await gzipFile(abs, join(destRoot, `${rel}.gz`));
} else {
const dest = join(destRoot, rel);
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(abs, dest);
console.log(` copied ${rel}`);
}
}
console.log(`OK → ${destRoot}`);
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ const api: StemApi = {
},
respondExecApproval: (id: string, decision: ExecDecision) =>
ipcRenderer.invoke('exec:resolveApproval', id, decision),
detectGitBash: () => ipcRenderer.invoke('exec:detectGitBash'),
getScratchUsage: () => ipcRenderer.invoke('exec:scratchUsage'),
clearScratch: (key: string) => ipcRenderer.invoke('exec:clearScratch', key),
onMcpChanged: (listener: () => void) => {
Expand Down
Loading
Loading