# dsh-doubao-memory — Complete tutorial

English root copy. 简体中文: [`tutorial.zh-CN.md`](tutorial.zh-CN.md) · 繁體中文: [`tutorial.zh-TW.md`](tutorial.zh-TW.md).
Plain-text versions ship alongside every language (`tutorial.txt`, `tutorial.zh-CN.txt`, `tutorial.zh-TW.txt`).

This tutorial is written to be the only document you need: install, configure, use it every day, and fix it when something breaks.

---

## 0. What you are actually running

Four moving parts. Knowing which one is broken is 90% of troubleshooting.

| Part | Lives where | What it does | How to restart |
| --- | --- | --- | --- |
| **Plugin** (`index.js` + `core.js`) | inside a DSH profile, loaded by the Cordis loader | injects the 【long-term memory】 section, registers the `remember` tool, listens to conversation events | **restart DSH** (bundles load at boot only) |
| **Memory page server** (`server.js` + `ui.html` + `core.js`) | a standalone `node` process | serves <http://127.0.0.1:7891/>, reads/writes the same JSON | `dsh-memory-manager.exe --restart`, or tray ▸ Restart |
| **Manager exe** (`dsh-memory-manager.exe`) | wherever you put it | extracts the bundled runtime, starts/stops the page server, tray menu, storage & port & language settings | just relaunch it |
| **Store** (`memory.json`) | `dataFile` (default `<plugin>/data/memory.json`, or your chosen storage folder) | the single source of truth both the plugin and the page read | no restart needed; every operation re-reads the file |

Two rules that explain most confusion:

1. **The page and the plugin are independent processes.** The page can be up while the plugin is not loaded (the agent then has no memory tool), and the plugin can be loaded while the page is down (the agent still has memories; only the UI is missing).
2. **Only the store is shared state.** If the page shows 0 entries while the agent "remembers things", they are reading two different files — see §3.4.

---

## 1. Install

### 1.1 Before you start

* Windows 10/11.
* Any DSH host already installed: DSH Desktop (MSIX), the DeepSeek Harness portable build, or the CLI (`dsh`).
* Nothing else. No Node install needed if you use the exe (it ships Node 24); with the script/manual route, Node 18+ must be resolvable.

Find your profile(s) first — the plugin attaches to a **profile**, not to "DSH" in the abstract:

```
%APPDATA%\com.yeagoo.dsh-desktop\harness\profiles\web     ← DSH Desktop (MSIX), booted by `dsh web`
%USERPROFILE%\.dsh\profiles\web                           ← CLI / portable builds
%USERPROFILE%\.dsh\profiles\desktop                       ← older desktop-style profile
```

A profile is just a folder containing `package.json`, `cordis.patch.yml`, `cordis.yml` and `node_modules`. `dsh web` means "boot the profile named `web`".

### 1.2 Route A — the manager exe (recommended)

1. Put `dsh-memory-manager.exe` anywhere writable (e.g. `D:\tools\`). It is a single ~34 MB file; nothing else is required.
2. Double-click it.
   * **First run** asks for the UI language → English / 简体中文 / 繁體中文. The choice is written to `%LOCALAPPDATA%\dsh-doubao-memory\config.json` and stays until you change it (tray ▸ Language).
   * It extracts the bundled Node runtime and the app files into `%LOCALAPPDATA%\dsh-doubao-memory\`, starts the page server and opens <http://127.0.0.1:7891/>.
   * A tray icon appears: `Open memory page`, `Copy address`, `Storage location…`, `Open storage folder`, `Change port…`, `Restart service`, `Stop service`, `Attach to DSH…`, `Open install folder`, `Language`, `Quit`.
3. Tray ▸ **Attach to DSH…** (or run `dsh-memory-manager.exe --install`). For every profile it finds it creates a junction, registers the bundle, writes the patch entry and points the plugin's `dsh-tools` peer at the host's copy.
4. **Restart DSH.**
5. Verify:
   * DSH ▸ *Settings → Plugins* lists `dsh-doubao-memory` (entry id `doubao-memory`, mounted);
   * open a new chat and ask "what do you remember about me?" — it should answer from the injected section rather than hunting the disk;
   * the memory page shows the same entries the agent can see.

CLI equivalents:

```bat
dsh-memory-manager.exe --install                    :: attach + start + open
dsh-memory-manager.exe --start                      :: start the page server only
dsh-memory-manager.exe --stop                       :: stop the page server
dsh-memory-manager.exe --status                     :: 0 healthy · 1 not running · 3 port owned by another app
dsh-memory-manager.exe --port 7900                  :: remember a new port (refused if taken)
dsh-memory-manager.exe --store "D:\dsh-memory-data" :: remember a new storage folder
dsh-memory-manager.exe --lang zh-TW                 :: switch UI language
dsh-memory-manager.exe --dir / --data / --version   :: show install dir / data file / version
```

### 1.3 Route B — `attach.ps1`

```powershell
# every profile it can find (MSIX + portable + CLI homes)
powershell -ExecutionPolicy Bypass -File .\attach.ps1

# also create a `web` profile in any home that lacks one
powershell -ExecutionPolicy Bypass -File .\attach.ps1 -CreateWeb

# one specific profile
powershell -ExecutionPolicy Bypass -File .\attach.ps1 -ProfileDir "C:\Users\you\.dsh\profiles\web"

# keep the store outside the plugin folder (recommended: an ASCII path)
powershell -ExecutionPolicy Bypass -File .\attach.ps1 -DataFile "D:/dsh-memory-data/memory.json"

# point the plugin's dsh-tools peer at a specific runtime (multi-release setups)
powershell -ExecutionPolicy Bypass -File .\attach.ps1 -RuntimeDir "D:\harness\node_modules\@deepseek-ai" -Force
```

The script is idempotent: `[skip]` means "already fine". It prints one line per change, and ends with `done: N/M profile(s) attached`.

### 1.4 Route C — manual, four steps

Given a profile directory `P`:

1. Link the plugin:
   ```bat
   mklink /J "P\node_modules\dsh-doubao-memory" "D:\plugins\dsh-doubao-memory"
   ```
   (copying the folder works too; a junction just keeps edits in one place.)
2. Register the bundle — in `P\package.json`, `dsh.profile.bundles` must contain `"dsh-doubao-memory"`.
3. Write the loader patch — append to `P\cordis.patch.yml`:
   ```yaml
   - id: doubao-memory
     name: dsh-doubao-memory
     config:
       enabled: true
       judgeMode: model
       inbox: true
   ```
   If the file only contains `[]`, replace that line — a bare `[]` plus a mapping is invalid YAML.
4. Peer dependency — the plugin imports `@deepseek-ai/dsh-tools` from the host:
   ```bat
   mklink /J "<plugin>\node_modules\@deepseek-ai\dsh-tools" "<host>\node_modules\@deepseek-ai\dsh-tools"
   ```

Then restart DSH.

> **Why step 4 exists:** the plugin's real path may sit on another drive, so Node's parent-directory walk never reaches `$DSH_HOME\profiles\node_modules` (the fallback DSH maintains for in-box packages). The junction makes the peer resolvable without duplicating it.

### 1.5 First-run checklist

```
[ ] memory page opens on http://127.0.0.1:7891/ (and http://127.0.0.1:7891/)
[ ] Settings → Plugins shows dsh-doubao-memory as mounted
[ ] a new chat can list memories (ask: "list my memories")
[ ] the page and the agent see the same number of entries
[ ] tray ▸ Storage location shows the folder you expect
```

---

## 2. Configure

### 2.1 Two configuration layers

| Layer | File | Wins over |
| --- | --- | --- |
| Deployment | profile `cordis.patch.yml` (`- id: doubao-memory` block) | the plugin's own defaults |
| User | `memory.json` → `settings` (written by the memory page) | deployment, for whitelisted keys |

Whitelisted keys a user-layer setting may override: `injectMode`, `judgeMode`, `maxRelevant`, `perCluster`, `minScore`, `showIndex`, `indexChars`, `maxBytes`, `maxEntries`, `recallMaxBytes`, `maxRecall`, `currentProject`. Switches (`enabled`, `autoExtract`) are also layered: **YAML off = hard off** (the page switch cannot turn it back on), page off = soft off.

### 2.2 Every key, and when to change it

| Key | Default | Change it when |
| --- | --- | --- |
| `enabled` | `true` | You want a global kill switch (e.g. a sensitive session) |
| `judgeMode` | `model` | `rules` if you want zero-token extraction; `off` if you want only explicit requests |
| `autoExtract` | `true` | Only meaningful with `judgeMode: rules` |
| `inbox` | `true` | `false` to let rule-extracted persona facts go live immediately |
| `injectMode` | `relevant` | `all` if you have very few memories and want them always visible |
| `maxBytes` | `8192` | Lower for cheap models / long contexts; raise if you have many project facts |
| `maxRelevant` | `6` | Raise when your questions span many topics |
| `perCluster` | `2` | Raise when one sentence names 3–4 things |
| `minScore` | `6` | Raise (8–10) if unrelated memories keep showing up; lower (4) if relevant ones are missed |
| `recallMaxBytes` | `16384` | Raise if "extract everything about X" still truncates |
| `maxRecall` | `40` | Raise for very large libraries |
| `showIndex` | `true` | `false` saves bytes but the model can no longer discover unselected entries |
| `indexChars` | `18` | `10–12` for cheaper indexes, `24+` for more recognisable titles |
| `currentProject` | `""` | Set it while you work inside one project so its memories are eligible without naming it |
| `dataFile` | `""` | Keep the store outside the plugin folder (recommended) |
| `pageServer` | `true` | `false` if you manage the page yourself |
| `pageHost` / `pagePort` | `127.5.0.1` / `7891` | Port collisions, or you prefer `127.0.0.1` |

Three ready-made profiles:

```yaml
# Minimal — few memories, cheapest prompt
config: { enabled: true, judgeMode: model, injectMode: all, maxBytes: 4096, showIndex: false }

# Balanced (default)
config: { enabled: true, judgeMode: model, injectMode: relevant, maxBytes: 8192, maxRelevant: 6, minScore: 6, showIndex: true }

# Long-context / many projects
config: { enabled: true, judgeMode: model, injectMode: relevant, maxBytes: 16384, maxRelevant: 10, perCluster: 3, minScore: 5,
          recallMaxBytes: 32768, indexChars: 24, currentProject: 'my-project' }
```

### 2.3 Port

* Default `127.5.0.1:7891`, and the server also binds `127.0.0.1` so `localhost:7891` works.
* Change it: tray ▸ **Change port…**, or `--port 7900`, or `pagePort` in the patch, then restart the page.
* **Occupied ports are refused, not hijacked.** The manager distinguishes "my own server" (reuses it) from "someone else's" (exit code `3` + a message). If you point it at a port a different app owns, nothing starts and nothing is killed.
* Changing the port restarts only the page server; the DSH plugin is unaffected (it does not use the port).

### 2.4 Storage folder

Rules (enforced by the manager):

1. the drive must exist;
2. every folder name must be ASCII: `A-Z a-z 0-9` plus space `_` `-` `.`;
3. the target must be empty, or already be your current store;
4. a bare drive root (`D:\`) is rejected.

Change it with tray ▸ **Storage location…** or `--store <dir>`. If the old folder still has memories you are asked:

```
The old folder still holds N memories:
  D:\old\memory.json
Copy them into the new folder?
  Yes = copy      No = switch only (old data stays)      Cancel = abort
```

After a successful switch the manager writes `dataFile` into every attached profile's `cordis.patch.yml` — **restart DSH** so the plugin follows. The page server is restarted automatically.

> Keep the store in an ASCII path. Windows tolerates Chinese/Unicode folders, but `wscript`, PowerShell 5.1 script files and some launchers mangle them; keeping the data path plain ASCII removes a whole class of failures.

### 2.5 Language

* Chosen once at install time (first launch modal), stored in `config.json.lang`.
* Switch any time: tray ▸ **Language**, or `--lang en|zh-CN|zh-TW`, or the memory page ▸ Settings ▸ Language.
* The manager mirrors the choice into `memory.json.settings.lang`; the page server injects it into the HTML (`window.__DSH_MEMORY_LANG__`), so both stay in sync.
* Docs follow the same set: `README.md` (English root), `README.zh-CN.md`, `README.zh-TW.md`, and the tutorial in all three, each as `.md` and `.txt`.

---

## 3. Daily use

### 3.1 The `remember` tool

| action | Required | Optional | Effect |
| --- | --- | --- | --- |
| `list` | — | — | every entry with its number, category, priority, status |
| `read` | `id` or `index` | — | full text + metadata of one entry |
| `add` | `content` | `category`, `priority`, `project`, `tags`, `ttlDays` | create; refuses duplicates |
| `update` | `id` or `index` | `content`, `category`, `priority`, `status`, `project`, `tags`, `ttlDays` | rewrite in place, id preserved, `updatedAt` refreshed |
| `remove` | `id` or `index` | — | delete one entry |
| `search` | `keyword` | — | match over content, tags and project |

Numbering is insertion order and identical in `list`, in the injected section and on the page — so "entry 3" always means the same thing.

The agent is instructed to **search first and update rather than add**: one fact, one entry, always the latest text.

### 3.2 Choosing the right category

| You are storing… | Use | Why |
| --- | --- | --- |
| a rule that has to hold on every turn | `global` | always injected |
| how they react, what they care about, what they push back on | `persona` | always injected, and it is what makes the assistant feel like it knows them |
| a path / script / pitfall bound to one repository | `project` (+ `project` name) | only eligible while that project is active |
| scratch context that only matters today | `ephemeral` | dies with the session |
| anything else worth keeping | `general` | keyword-selected |

If you want "always injected" without thinking about categories, start the text with `[全局]` — the plugin strips the marker and treats the entry as `global`.

### 3.2b What a `persona` entry has to look like

`persona` is the category that makes the assistant feel like it knows you — and the one that turns creepy when written badly. Third-person verdicts about a person read like a file being kept on them behind their back; that reaction ("why are you keeping notes on me?") is a signal you have written a profile instead of a record.

What separates a usable `persona` entry from a creepy one is not tone, it is **evidence**. A well-formed entry has four properties:

| Property | What it means | Why it matters |
| --- | --- | --- |
| **dated** | it carries the date of the observation | an undated observation cannot expire, and persona notes age |
| **scene-bound** | it names what actually happened | a scene can be confirmed; a character trait cannot |
| **anchored** | it keeps their own words or a concrete action | paraphrasing is where invention enters |
| **one line of reading** | judgement comes last and stays short | the evidence stays intact and the interpretation is clearly separate |

A badly formed entry has the opposite profile: adjective-first, undated, scene-free, nothing the reader could confirm or deny. That profile is what makes a memory file read like a dossier — and it is the one users ask to have deleted.

Template (structure only, no content): `YYYY-MM-DD  <what happened>  — "<their words>"  — <one line of reading>`.

If you cannot recall a scene and the wording, write nothing — an empty persona section is better than an invented one.

Practical consequences:

* Persona entries age. Add the date; when the observation stops matching reality, `update` it instead of stacking a contradicting twin.
* Never store a persona entry that only restates a `global` rule in psychological language — the rule itself belongs in `global`.
* Persona entries are always injected, so they are the most expensive bytes you own. Every sentence must earn its place.

### 3.3 What gets injected, and why the numbers matter

Order: `global` + `persona` (all, subject to budget) → `project` (eligible ones that also match) → `ephemeral` (same session) → `general` (keyword-matched) → a numbered index of everything else.

* A sentence with several topics is split into *coverage units* — mentioned tags first, then punctuation-separated clauses — and each unit contributes its own entries, so one loud topic cannot monopolise the budget.
* Explicit recall ("extract every memory about X") bypasses `minScore`/`maxRelevant` and widens the budget to `recallMaxBytes`.
* Nothing that is `pending`, `rejected` or expired is ever injected.

**Read the dashboard before you complain about cost.** It shows the byte/token split of the current injection, and warns when always-on memories exceed 40–90 % of the budget — that is your cue to demote project facts or trim globals.

### 3.4 Workflows worth knowing

**Project switching.** Keep project facts as `project` entries, then set `currentProject` when you switch (page ▸ Settings, or edit the patch). Their entries are then eligible by keyword even when you don't name the project; unset it and they go dormant.

**Recording a pitfall.** Just hit the problem in chat. Under `judgeMode: model` the agent decides it is worth keeping, searches for an existing note on the same topic, and writes or updates it — usually with tags like `pitfall` / `lesson`. You do not have to say "remember".

**Fixing a wrong memory.** Page ▸ Memory Hub ▸ click the entry ▸ edit the text, change category/priority/TTL ▸ **Save**, or **Delete** (two-step confirmation: click, then type the confirmation word). Edits take effect on the agent's next turn; no restart needed.

**Inbox discipline.** Rule-extracted persona facts (only with `judgeMode: rules`) land in the inbox as `pending`. Accept the ones that are true, reject the rest; a rejected entry never reaches the prompt.

**Backups.** `GET /api/export` (page ▸ Export JSON) writes the whole store. The manager also leaves `memory.json.bak_*` files next to the store whenever it migrates or rewrites.

---

## 4. Troubleshooting

### 4.1 The agent has no memory at all

| Check | Command / place | Expected |
| --- | --- | --- |
| DSH restarted after install? | — | bundles load at boot only; restart is mandatory |
| Plugin mounted? | Settings → Plugins | `dsh-doubao-memory` present, mounted |
| Bundle registered? | profile `package.json` → `dsh.profile.bundles` | contains `dsh-doubao-memory` |
| Patch applied? | `dsh web --dump-config` (CLI) or profile `cordis.patch.yml` | contains the `- id: doubao-memory` block |
| Junction healthy? | `<profile>\node_modules\dsh-doubao-memory` | points at the plugin dir |
| Peer resolvable? | `<plugin>\node_modules\@deepseek-ai\dsh-tools\package.json` | exists |

Re-run `attach.ps1`; every line is either `[ok]`, `[skip]` or `[fail]` — `[fail]` tells you which link is wrong.

### 4.2 The memory page does not open

```bat
dsh-memory-manager.exe --status      :: 0 = healthy, 1 = not running, 3 = port owned by another app
dsh-memory-manager.exe --start       :: start it
```

> Reading that exit code: the manager is a GUI-subsystem executable, so PowerShell's `&` returns before it finishes and `$LASTEXITCODE` stays empty. Use `cmd /c "dsh-memory-manager.exe --status & echo %ERRORLEVEL%"` or `Start-Process -Wait -PassThru …` when you need the number.

* **exit 3** → someone else owns the port. `--port 7900`, or tray ▸ Change port…
* Start succeeds but the browser shows nothing → check `http://127.0.0.1:7891/` as well as `127.5.0.1`.
* The page worked and then went blank → the process died (killed by a cleanup script, a reboot, or the OOM killer). `--start` brings it back; the plugin also spawns it at DSH boot.

### 4.3 "My memories are gone!"

Almost never true. Check in this order:

1. **Different store.** Page ▸ Settings shows the data path; compare with what the plugin's patch says. A `--store` switch or an old `dataFile` value explains "0 entries" instantly.
2. **Different host.** Two DSH installations (MSIX + portable) can each have their own profile. Run `attach.ps1` for both.
3. **Pending / rejected.** The dashboard counts them separately; the inbox is where unapproved facts wait.
4. **Expired.** `expiresAt` in the past hides an entry silently — filter chips ▸ ⏰ Expired.
5. **The JSON is actually damaged** (rare: only interrupted writes). Restore the newest `memory.json.bak_*` next to it, or re-import an export.
6. **The file is fine but unreadable** — check it parses: `node -e "JSON.parse(require('fs').readFileSync('memory.json','utf8'))"`.

### 4.4 Bad memories keep getting in

* Switch to `judgeMode: model` (the agent judges) or `off` (only explicit requests) — the regex fallback is the noisy path.
* Keep `inbox: true` so rule-extracted persona facts need approval.
* Delete the offending entries (page, two-step confirm). The extractor also refuses sentences that are negated or meta ("when the user says remember…"), and technical instructions.

### 4.5 Prompt cost too high / model gets distracted

* Dashboard ▸ the bar tells you which section is expensive; always-on (`global` + `persona`) is the usual culprit.
* Demote project facts to `project` and set `currentProject` only when needed.
* Lower `maxBytes`, `maxRelevant`, `perCluster`; raise `minScore`.
* Turn `showIndex` off for the leanest prompt (you lose discoverability).
* Persona notes are cheap and useful — trim rule-like globals first, not persona.

### 4.6 Windows-specific traps

| Trap | Symptom | Fix |
| --- | --- | --- |
| Task Scheduler auto-start | Windows Defender may treat a self-created scheduled task as a heuristic trojan (task registration fails with `0x800700E1`) | do not create one; put the exe in the Startup folder, or start it from the tray after logon |
| PowerShell 5.1 + UTF-8 scripts | `attach.ps1` fails to parse, Chinese messages turn into mojibake | scripts ship as UTF-8 **with BOM**; do not re-save them as BOM-less UTF-8 |
| `wscript` + non-ASCII paths | the launcher cannot find `server.js` | launchers are pure ASCII and derive paths at runtime; keep the plugin/store in ASCII paths |
| Antivirus slows or blocks the first run | extraction takes long, or `node.exe` is quarantined | allow the exe / its `%LOCALAPPDATA%` folder |
| Junction vs. copy | edits to the source don't appear | a junction reflects edits immediately; a copy does not — re-copy or switch to a junction |
| Two DSH hosts at once | page serves one store, the other host uses another | point both profiles at the same `dataFile` |

### 4.7 Uninstall

1. Delete the `- id: doubao-memory` block from each profile's `cordis.patch.yml` and `dsh-doubao-memory` from `dsh.profile.bundles`.
2. Remove `<profile>\node_modules\dsh-doubao-memory` (and the `dsh-tools` junction inside the plugin if you created it).
3. Restart DSH.
4. Delete the page process (`--stop`), the plugin folder, and — if you want the memories gone — the `memory.json` store and `%LOCALAPPDATA%\dsh-doubao-memory`.
5. Memories live only in that JSON file; nothing is uploaded anywhere.

---

## Appendix A — file map

```
dsh-doubao-memory/
├── index.js              plugin entry (system prompt section/context, remember tool, events, page spawn)
├── core.js               pure logic: model, migration, tokenizer, selection, rendering, token estimate
├── server.js             memory page server (node:http, loopback only)
├── ui.html               memory page UI (single file, no external assets, 3 languages)
├── cordis.patch.yml      bundle patch (inserts the doubao-memory row)
├── package.json          package manifest (dsh.bundle.patch, peer: dsh-tools)
├── attach.ps1            multi-host installer (junction + bundles + patch + peer + dataFile)
├── build-single-exe.ps1  rebuilds the single-file manager
├── build-release.ps1     packages release\dsh-doubao-memory-<version>.zip (+ leak check)
├── tools/md2txt.mjs      regenerates the .txt docs from the .md sources
├── manager/
│   ├── DshMemoryManager.cs   single-file manager source (embeds Node + docs)
│   └── Program.cs            legacy "same-folder" manager source (not shipped)
├── start-memory-page.vbs / .bat, stop-memory-page.bat, 启动记忆页.bat, 停止记忆页.bat
├── docs/                 tutorial.md/.txt (en, zh-CN, zh-TW), NODE-LICENSE.txt
├── examples/             cordis.patch.yml.example
├── data/memory.json      default store (auto-created)
├── README.md / README.zh-CN.md / README.zh-TW.md  (+ .txt)
└── LICENSE
```

## Appendix B — endpoints

| Method | Path | Body | Notes |
| --- | --- | --- | --- |
| GET | `/` | — | memory page HTML (language injected server-side) |
| GET | `/api/state?q=&session=` | — | config, settings, entries, dashboard, preview |
| GET | `/api/export` | — | full JSON |
| POST | `/api/entry/add` | `{content, category?, project?, priority?, tags?, ttlDays?}` | 400 empty, 409 duplicate |
| POST | `/api/entry/update` | `{id, ...fields}` | `ttlDays`, `clearExpiry` |
| POST | `/api/entry/delete` | `{id}` | one at a time by design |
| POST | `/api/inbox` | `{id, action: accept\|reject, content?}` | |
| POST | `/api/inbox/bulk` | `{action}` | |
| POST | `/api/settings` | `{enabled?, autoExtract?, judgeMode?, injectMode?, showIndex?, lang?, maxBytes?, …}` | `{reset:true}` restores defaults |
| POST | `/api/preview` | `{q?, session?}` | renders the injection |

All POSTs require `Content-Type: application/json`.

## Appendix C — quick reference

```bat
:: manager
dsh-memory-manager.exe --install / --start / --stop / --restart / --status
dsh-memory-manager.exe --port 7900 --store D:\dsh-memory-data --lang zh-CN

:: attach to every detected profile, store outside the plugin
powershell -ExecutionPolicy Bypass -File attach.ps1 -DataFile "D:/dsh-memory-data/memory.json"

:: rebuild the single-file exe (needs .NET Framework 4.x csc.exe)
powershell -ExecutionPolicy Bypass -File build-single-exe.ps1

:: package the release zip (leak-checks personal paths, never includes real memories)
powershell -ExecutionPolicy Bypass -File build-release.ps1

:: regenerate the .txt docs from the Markdown sources
node tools/md2txt.mjs README.md docs/tutorial.md
```

License: MIT. The bundled Node runtime is MIT as well — see `docs/NODE-LICENSE.txt`.
