vexp-cli 3.2.1 → 3.2.4

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.
@@ -1983,6 +1983,22 @@ function extractCodexVexpSection(content) {
1983
1983
  const m = content.match(CANONICAL_VEXP_TOML_SECTION_RE);
1984
1984
  return m ? m.join("\n") : "";
1985
1985
  }
1986
+ /** True when ~/.codex/config.toml carries a hand-written [mcp_servers.vexp]
1987
+ * section — one `vexp use` and `vexp setup` will never rewrite, so a caller
1988
+ * that promised to repoint Codex can say it did not. */
1989
+ export function codexGlobalSectionIsUserManaged() {
1990
+ const home = os.homedir();
1991
+ if (!home)
1992
+ return false;
1993
+ let content = "";
1994
+ try {
1995
+ content = fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf-8");
1996
+ }
1997
+ catch {
1998
+ return false;
1999
+ }
2000
+ return isUserManagedCodexSection(extractCodexVexpSection(content));
2001
+ }
1986
2002
  /** True when the existing [mcp_servers.vexp] section was written by the user
1987
2003
  * (not by vexp) and must NOT be overwritten. */
1988
2004
  function isUserManagedCodexSection(section) {
package/dist/cli.js CHANGED
@@ -18,6 +18,8 @@ import { isWsl, windsurfWslBridgeNote } from "./agent-config.js";
18
18
  import { runServe } from "./serve.js";
19
19
  import { runDoctor } from "./doctor.js";
20
20
  import { socketPathFor } from "./socket-path.js";
21
+ import { isStateHome } from "./state-home.js";
22
+ import { forgetRegistryRows, removeIndexArtifacts } from "./forget.js";
21
23
  import { mutableOutput, askSecretOn } from "./secret-prompt.js";
22
24
  import { isTraceEnabled } from "./trace.js";
23
25
  import { resolveParentWorkspace, listWorkspaceRepos, addRepoToWorkspace, } from "./workspace-repos.js";
@@ -48,6 +50,9 @@ const AUTOSTART_SKIP = new Set([
48
50
  // followed by `daemons` bring the stopped daemon straight back
49
51
  // (3.1 e2e, 2026-08).
50
52
  "daemons", "stop",
53
+ // `vexp forget` exists to make a workspace stay down; booting one first
54
+ // would be the joke `stop` used to play.
55
+ "forget", "unregister",
51
56
  ]);
52
57
  program.hook("preAction", async (_thisCmd, actionCmd) => {
53
58
  // One-shot config migration for the multi-session fix — de-pins a legacy
@@ -545,10 +550,62 @@ program
545
550
  console.error(`No such directory: ${target}`);
546
551
  process.exit(1);
547
552
  }
553
+ // A stop is not a good-bye: while `vexp serve` runs, every registered
554
+ // workspace that still has a manifest is brought back within a minute,
555
+ // and a user who wanted it gone read that as vexp ignoring him (field
556
+ // report, 2026-09-19). Say which command means gone. Printed before the
557
+ // stop because runBinary exits the process with the child.
558
+ if (fs.existsSync(path.join(target, ".vexp", "manifest.json"))) {
559
+ console.log(chalk.dim(` (a login supervisor restarts registered workspaces within a minute — to unregister this one for good: vexp forget "${target}")`));
560
+ }
548
561
  // daemon-cmd stop resolves the daemon from its cwd, so run it from the
549
562
  // target workspace — this is what makes `vexp stop <workspace>` global.
550
563
  runBinary(binaryPath, ["daemon-cmd", "stop"], { cwd: target });
551
564
  });
565
+ program
566
+ .command("forget [workspace]")
567
+ .alias("unregister")
568
+ .description("Unregister a workspace for good: stop its daemon, drop it from ~/.vexp/daemons.json and delete its index files, so no launcher brings it back (default: current directory). Safe on the home directory: only index files go — config, licence and registry stay")
569
+ .option("--purge", "Also delete the whole .vexp/ directory, config included (refused for the home directory)")
570
+ .action(async (workspace, opts) => {
571
+ const target = path.resolve(workspace ?? process.cwd());
572
+ if (!fs.existsSync(target)) {
573
+ console.error(`No such directory: ${target}`);
574
+ process.exit(1);
575
+ }
576
+ const home = isStateHome(target);
577
+ if (opts.purge && home) {
578
+ console.error(chalk.red(`${target} is your home directory: .vexp there is vexp's state dir (config, licence, daemon registry). --purge is refused; without it only the index files go.`));
579
+ process.exit(1);
580
+ }
581
+ const binaryPath = ensureBinary();
582
+ // 1. The daemon. `daemon-cmd stop` resolves it from its cwd.
583
+ const stop = spawnSync(binaryPath, ["daemon-cmd", "stop"], {
584
+ cwd: target,
585
+ encoding: "utf-8",
586
+ env: binaryEnv(binaryPath),
587
+ stdio: ["ignore", "pipe", "pipe"],
588
+ });
589
+ const stopSaid = `${stop.stdout ?? ""}${stop.stderr ?? ""}`.trim().split(/\r?\n/)[0] ?? "";
590
+ console.log(` daemon: ${stopSaid || (stop.error ? stop.error.message : "stopped")}`);
591
+ // 2. The registry — this row and any connected repo routed to its socket.
592
+ const dropped = forgetRegistryRows(target, socketPathFor(target));
593
+ console.log(` registry: ${dropped.length ? `dropped ${dropped.join(", ")}` : "no row for this workspace"}`);
594
+ // 3. The files that would bring it back. `--purge` takes config too.
595
+ const vexpDir = path.join(target, ".vexp");
596
+ if (opts.purge) {
597
+ fs.rmSync(vexpDir, { recursive: true, force: true });
598
+ console.log(` files: removed ${vexpDir}`);
599
+ }
600
+ else {
601
+ const removed = removeIndexArtifacts(vexpDir);
602
+ console.log(` files: ${removed.length ? `removed ${removed.join(", ")}` : "no index files"}${home ? " (home directory: config, licence and registry untouched)" : ""}`);
603
+ }
604
+ console.log(chalk.green(`✓ ${target} forgotten`));
605
+ if (home) {
606
+ console.log(chalk.dim(" Nothing starts a daemon on the home directory any more: every launcher refuses it (VEXP_ALLOW_HOME_WORKSPACE=1 overrides)."));
607
+ }
608
+ });
552
609
  program
553
610
  .command("shield <action>")
554
611
  .description("PII/secret shield: 'scan' reports what comments and string literals would hand to any AI agent (free, read-only, local)")
@@ -1255,7 +1312,16 @@ program
1255
1312
  const binaryPath = ensureBinary();
1256
1313
  const mcpServerPath = getMcpServerPath();
1257
1314
  // Import lazily so configureCodexGlobal's side effects (TOML rewrite + legacy cleanup) only fire here.
1258
- const { configureSelectedAgents } = await import("./agent-config.js");
1315
+ const { configureSelectedAgents, codexGlobalSectionIsUserManaged } = await import("./agent-config.js");
1316
+ // A hand-written [mcp_servers.vexp] is never rewritten (no-clobber), and
1317
+ // this command used to print its success line anyway — the user then
1318
+ // "repointed" Codex and watched it launch from the old cwd (field
1319
+ // report, 2026-09-19). Say what did not happen and how to do it by hand.
1320
+ if (codexGlobalSectionIsUserManaged()) {
1321
+ console.error(chalk.yellow(`~/.codex/config.toml has a hand-written [mcp_servers.vexp] section, and vexp never rewrites one: Codex keeps launching it exactly as written, so nothing changed.`));
1322
+ console.error(chalk.dim(` Point it yourself — under [mcp_servers.vexp] add cwd = ${JSON.stringify(resolved)}, and under [mcp_servers.vexp.env] add VEXP_WORKSPACE = ${JSON.stringify(resolved)} — or delete the section and run 'vexp use' again.`));
1323
+ process.exit(1);
1324
+ }
1259
1325
  const version = (await getInstalledVersion()) ?? CLI_VERSION;
1260
1326
  // "Codex" is a no-op file-wise in non-detect mode but the shared flow
1261
1327
  // also writes codex global config via configureCodexGlobal — pass an
package/dist/doctor.js CHANGED
@@ -10,6 +10,7 @@ import { resolveParentWorkspace, listWorkspaceRepos } from "./workspace-repos.js
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { parentPid } from "./mcp-supervisor.js";
12
12
  import { slowMountNotice } from "./slow-mount.js";
13
+ import { vexpHome, isStateHome } from "./state-home.js";
13
14
  // `vexp doctor` — audit the vexp MCP/daemon state WITHOUT connecting to a daemon.
14
15
  // Surfaces the failure modes behind the Codex drift report: stale daemons.json
15
16
  // entries, wrong-workspace resolution, mixed Codex transport (url+stdio),
@@ -17,12 +18,6 @@ import { slowMountNotice } from "./slow-mount.js";
17
18
  const OK = chalk.green("OK");
18
19
  const WARN = chalk.yellow("WARN");
19
20
  const BAD = chalk.red("FAIL");
20
- function vexpHome() {
21
- const h = process.env.VEXP_HOME;
22
- if (h && path.isAbsolute(h))
23
- return h;
24
- return os.homedir();
25
- }
26
21
  /** Walk up for the nearest INITIALIZED .vexp (manifest/index), then bare .vexp,
27
22
  * then .git — mirrors discover_workspace_root / discoverWorkspaceRoot. */
28
23
  function discoverWorkspaceRoot(start) {
@@ -773,6 +768,13 @@ async function doctorChecks(onWorkspace) {
773
768
  return false;
774
769
  } })();
775
770
  line(stale ? WARN : OK, `${root} → ${s}${stale ? " (STALE: socket file gone)" : ""}`);
771
+ // A row for the home directory is never right: its .vexp is vexp's
772
+ // state dir, and a daemon there indexes everything under home. Every
773
+ // launcher now refuses it; the row and the daemon behind it are the
774
+ // residue (field report, 2026-09-19).
775
+ if (isStateHome(root)) {
776
+ line(BAD, `${root} is your home directory — never a workspace (its .vexp is vexp's state dir). A daemon here indexes everything under home. Run: vexp forget "${root}" (stops it, drops this row, deletes only its index files — licence, config and registry stay)`);
777
+ }
776
778
  }
777
779
  const inReg = entries.some(([r]) => r.toLowerCase() === ws.root.toLowerCase());
778
780
  if (!inReg)
@@ -837,7 +839,11 @@ async function doctorChecks(onWorkspace) {
837
839
  continue;
838
840
  }
839
841
  const toml = fs.readFileSync(file, "utf-8");
840
- const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[[A-Za-z_]|$)/);
842
+ // The section INCLUDES its own subsections ([mcp_servers.vexp.env],
843
+ // .http_headers): the pin lives in .env, and a lookahead that stopped at
844
+ // any '[' hid it, so doctor reported "stdio (command)" for a stanza it
845
+ // could not tell apart from an unpinned one.
846
+ const m = toml.match(/\n?\[mcp_servers\.vexp\][\s\S]*?(?=\n\[(?!mcp_servers\.vexp\.)|$)/);
841
847
  const section = m ? m[0] : "";
842
848
  if (!section) {
843
849
  line(OK, `${label}: no [mcp_servers.vexp] stanza`);
@@ -852,7 +858,9 @@ async function doctorChecks(onWorkspace) {
852
858
  line(OK, `${label}: transport http (url)`);
853
859
  else if (hasCmd) {
854
860
  const wsm = section.match(/VEXP_WORKSPACE\s*=\s*['"]([^'"]+)['"]/);
855
- line(OK, `${label}: transport stdio (command)${wsm ? `, VEXP_WORKSPACE=${wsm[1]}` : ""}`);
861
+ const cwdm = section.match(/^\s*cwd\s*=\s*['"]([^'"]+)['"]/m);
862
+ const pins = [wsm ? `VEXP_WORKSPACE=${wsm[1]}` : "", cwdm ? `cwd=${cwdm[1]}` : ""].filter(Boolean).join(", ");
863
+ line(OK, `${label}: transport stdio (command)${pins ? `, ${pins}` : ", no workspace pin — the server resolves from Codex's cwd"}`);
856
864
  }
857
865
  else
858
866
  line(WARN, `${label}: stanza present but neither url nor command found`);
package/dist/forget.js ADDED
@@ -0,0 +1,120 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { vexpHome } from "./state-home.js";
4
+ /**
5
+ * `vexp forget`: the pieces that make a workspace stay forgotten.
6
+ *
7
+ * Stopping a daemon is not enough on a machine running `vexp serve`: the
8
+ * supervisor resurrects every registry row whose manifest still exists,
9
+ * within a minute. Removing the registry row by hand is not enough either:
10
+ * a daemon that is still alive is written back on the next health tick, and
11
+ * the daemon re-registers itself on every start. What keeps a workspace
12
+ * down is all three at once — daemon stopped, row gone, manifest and index
13
+ * gone — and that is what `vexp forget` does (field report, 2026-09-19: a
14
+ * user did each of the three by hand, in turn, four times).
15
+ */
16
+ /** Everything a daemon or an index run leaves in `<workspace>/.vexp`.
17
+ * Config is absent on purpose (`vexp.toml`, `workspace.json`,
18
+ * `parent_workspace.json`, `.gitattributes`, `.gitignore`): forgetting a
19
+ * workspace removes what brings its daemon back, not what the user wrote.
20
+ * At the home directory the same `.vexp` is vexp's state dir, and this list
21
+ * is exactly what may go there — `config.toml`, the licence tokens,
22
+ * `daemons.json`, `mcp.pid`/`mcp.token`, `gpu-unusable`, models and plugins
23
+ * are not on it. Keep in sync with vexp-core hook_installer VEXP_IGNORE_ENTRIES. */
24
+ export const INDEX_ARTIFACTS = [
25
+ "index.db",
26
+ "index.db-wal",
27
+ "index.db-shm",
28
+ "index.lock",
29
+ "manifest.json",
30
+ "coverage.json",
31
+ "daemon.pid",
32
+ "daemon.pipe",
33
+ "daemon.sock",
34
+ "healthy",
35
+ "start-blocked",
36
+ "daemon-job-warning",
37
+ "mcp.port",
38
+ "daily-limit.json",
39
+ ];
40
+ /** Rotating logs and per-session markers, matched by prefix. */
41
+ export const INDEX_ARTIFACT_PREFIXES = [
42
+ "daemon.log",
43
+ "vexp.log",
44
+ "stop-gate-",
45
+ "idle-gate-",
46
+ "task-",
47
+ ];
48
+ /** Per-session scratch directories the hooks write. */
49
+ export const INDEX_ARTIFACT_DIRS = ["edit-hints", "search-hints", "read-hints"];
50
+ /** True when an entry of `.vexp/` is index residue rather than config. */
51
+ export function isIndexArtifact(name, isDir) {
52
+ if (isDir)
53
+ return INDEX_ARTIFACT_DIRS.includes(name);
54
+ if (INDEX_ARTIFACTS.includes(name))
55
+ return true;
56
+ return INDEX_ARTIFACT_PREFIXES.some((p) => name === p || name.startsWith(p));
57
+ }
58
+ /** Delete the index artifacts in `vexpDir`, nothing else. Returns what went. */
59
+ export function removeIndexArtifacts(vexpDir) {
60
+ const removed = [];
61
+ let entries;
62
+ try {
63
+ entries = fs.readdirSync(vexpDir, { withFileTypes: true });
64
+ }
65
+ catch {
66
+ return removed;
67
+ }
68
+ for (const e of entries) {
69
+ if (!isIndexArtifact(e.name, e.isDirectory()))
70
+ continue;
71
+ try {
72
+ fs.rmSync(path.join(vexpDir, e.name), { recursive: true, force: true });
73
+ removed.push(e.name);
74
+ }
75
+ catch {
76
+ /* a file the daemon still holds on Windows; reported by omission */
77
+ }
78
+ }
79
+ return removed;
80
+ }
81
+ function registryFile() {
82
+ return path.join(vexpHome(), ".vexp", "daemons.json");
83
+ }
84
+ /** The registry without every row that names `target` or routes to its
85
+ * socket (a connected repo mapped to this workspace's socket is dangling
86
+ * once the workspace is gone). Windows keys are lowercased by the daemon. */
87
+ export function registryRowsWithout(reg, target, targetSocket) {
88
+ const canon = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
89
+ const t = canon(target);
90
+ const kept = {};
91
+ const dropped = [];
92
+ for (const [ws, sock] of Object.entries(reg)) {
93
+ if (canon(ws) === t || sock === targetSocket)
94
+ dropped.push(ws);
95
+ else
96
+ kept[ws] = sock;
97
+ }
98
+ return { kept, dropped };
99
+ }
100
+ /** Apply `registryRowsWithout` to ~/.vexp/daemons.json. */
101
+ export function forgetRegistryRows(target, targetSocket) {
102
+ const p = registryFile();
103
+ let reg = {};
104
+ try {
105
+ reg = JSON.parse(fs.readFileSync(p, "utf-8"));
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ const { kept, dropped } = registryRowsWithout(reg, target, targetSocket);
111
+ if (dropped.length === 0)
112
+ return [];
113
+ try {
114
+ fs.writeFileSync(p, JSON.stringify(kept, null, 2), "utf-8");
115
+ }
116
+ catch {
117
+ return [];
118
+ }
119
+ return dropped;
120
+ }
package/dist/serve.js CHANGED
@@ -5,6 +5,7 @@ import * as path from "path";
5
5
  import { spawn } from "child_process";
6
6
  import { getBinaryPath, binaryEnv } from "./binary.js";
7
7
  import { ensureMcpHttpServer } from "./mcp-supervisor.js";
8
+ import { isStateHome } from "./state-home.js";
8
9
  const HEALTH_INTERVAL_MS = 60_000;
9
10
  const DEFAULT_MCP_PORT = 7821;
10
11
  function registryPath() {
@@ -60,6 +61,36 @@ function rotateLogIfLarge() {
60
61
  }
61
62
  catch { /* missing */ }
62
63
  }
64
+ /** The daemon's log, opened the way `vexp-core daemon-cmd start` opens it:
65
+ * the previous boot rotated to daemon.log.1, this boot in daemon.log. The
66
+ * daemon writes its log to stderr and nowhere else, so a launcher that
67
+ * spawns it with stdio ignored gets a daemon that logs nothing at all — a
68
+ * 3.2.1 daemon answered requests for hours while daemon.log still ended at
69
+ * the crash of the day before, until a `daemon-cmd restart` brought the log
70
+ * back (field report, 2026-09-18). A rotation that fails (the file is still
71
+ * held open) appends instead: the evidence of the last boot is what a user
72
+ * restarting a daemon needs most. "ignore" when the file cannot be opened
73
+ * at all: a daemon without a log beats no daemon. */
74
+ export function openDaemonLog(workspaceRoot) {
75
+ const dir = path.join(workspaceRoot, ".vexp");
76
+ const log = path.join(dir, "daemon.log");
77
+ let flags = "w";
78
+ if (fs.existsSync(log)) {
79
+ try {
80
+ fs.rmSync(path.join(dir, "daemon.log.1"), { force: true });
81
+ fs.renameSync(log, path.join(dir, "daemon.log.1"));
82
+ }
83
+ catch {
84
+ flags = "a";
85
+ }
86
+ }
87
+ try {
88
+ return fs.openSync(log, flags);
89
+ }
90
+ catch {
91
+ return "ignore";
92
+ }
93
+ }
63
94
  async function resurrectDaemon(workspaceRoot, socketPath) {
64
95
  const manifest = path.join(workspaceRoot, ".vexp", "manifest.json");
65
96
  if (!fs.existsSync(manifest))
@@ -83,11 +114,25 @@ async function resurrectDaemon(workspaceRoot, socketPath) {
83
114
  // in vexp-core, which spawns every child with CREATE_NO_WINDOW
84
115
  // (packages/vexp-core/src/proc.rs). `windowsHide` would not help here:
85
116
  // Windows ignores it next to DETACHED_PROCESS.
86
- const child = spawn(binary, ["daemon", "--workspace", workspaceRoot, "--socket", socketPath], {
87
- detached: true,
88
- stdio: "ignore",
89
- env: binaryEnv(binary),
90
- });
117
+ //
118
+ // stdout/stderr go to the daemon's own log file, never to a terminal:
119
+ // a file handle is not a console, and it is what `daemon-cmd start`
120
+ // hands the same detached daemon. See openDaemonLog.
121
+ const log = openDaemonLog(workspaceRoot);
122
+ let child;
123
+ try {
124
+ child = spawn(binary, ["daemon", "--workspace", workspaceRoot, "--socket", socketPath], {
125
+ detached: true,
126
+ stdio: ["ignore", log, log],
127
+ env: binaryEnv(binary),
128
+ });
129
+ }
130
+ finally {
131
+ // The child holds its own handle from here; ours would only keep the
132
+ // file open (and unrotatable on Windows) for the supervisor's lifetime.
133
+ if (typeof log === "number")
134
+ fs.closeSync(log);
135
+ }
91
136
  child.unref();
92
137
  appendLog(`daemon spawned: ${workspaceRoot} (pid=${child.pid})`);
93
138
  return true;
@@ -143,6 +188,22 @@ export function isLinkedWorktree(ws) {
143
188
  return false;
144
189
  }
145
190
  }
191
+ /** Why a registry row must never be spawned by this supervisor, or null.
192
+ * The home directory is refused by the daemon itself (vexp-core
193
+ * forbidden_workspace_reason); vetoing it here too spares a spawn per
194
+ * minute that would only write "daemon did not start" to ~/.vexp
195
+ * (field report, 2026-09-19: the supervisor was one of the launchers
196
+ * that kept bringing the home daemon back). */
197
+ export function resurrectionVeto(ws) {
198
+ if (isStateHome(ws)) {
199
+ return "the home directory is vexp's state dir, never a workspace — `vexp forget` removes this row";
200
+ }
201
+ if (!fs.existsSync(path.join(ws, ".vexp", "manifest.json")))
202
+ return "no manifest";
203
+ if (isLinkedWorktree(ws))
204
+ return "linked git worktree — on-demand only, never resurrected";
205
+ return null;
206
+ }
146
207
  export function resurrectionPlan(reg) {
147
208
  const bySock = new Map();
148
209
  for (const [ws, sock] of Object.entries(reg)) {
@@ -186,13 +247,9 @@ async function resurrectAll() {
186
247
  continue;
187
248
  }
188
249
  seenKeys.add(canonical);
189
- const manifest = path.join(ws, ".vexp", "manifest.json");
190
- if (!fs.existsSync(manifest)) {
191
- appendLog(`registry prune: ${ws} (no manifest)`);
192
- continue;
193
- }
194
- if (isLinkedWorktree(ws)) {
195
- appendLog(`registry prune: ${ws} (linked git worktree — on-demand only, never resurrected)`);
250
+ const veto = resurrectionVeto(ws);
251
+ if (veto) {
252
+ appendLog(`registry prune: ${ws} (${veto})`);
196
253
  continue;
197
254
  }
198
255
  pruned[ws] = sock;
@@ -0,0 +1,23 @@
1
+ import * as os from "os";
2
+ import * as path from "path";
3
+ /** The directory vexp keeps its own state in (`<here>/.vexp`): VEXP_HOME when
4
+ * set to an absolute path, else the OS home. */
5
+ export function vexpHome() {
6
+ const h = process.env.VEXP_HOME;
7
+ if (h && path.isAbsolute(h))
8
+ return h;
9
+ return os.homedir();
10
+ }
11
+ /** True when `dir` IS the home / VEXP_HOME directory. Its `.vexp` is vexp's
12
+ * state dir (config, licence, daemons.json), never a workspace: a daemon
13
+ * there indexes everything under home, and deleting the directory to be rid
14
+ * of it takes the licence and the registry along (field report, 2026-09-19).
15
+ * Lockstep with vexp-core utils::forbidden_workspace_reason and
16
+ * vexp-mcp isVexpStateHome; VEXP_ALLOW_HOME_WORKSPACE=1 lifts it everywhere. */
17
+ export function isStateHome(dir) {
18
+ if (process.env.VEXP_ALLOW_HOME_WORKSPACE === "1")
19
+ return false;
20
+ const a = path.resolve(dir);
21
+ const b = path.resolve(vexpHome());
22
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
23
+ }