jeo-code 0.4.9 → 0.5.1

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/README.ja.md CHANGED
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
150
150
  ## 変更履歴 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
+ - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
153
155
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
154
156
  - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
155
157
  - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
156
- - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
157
- - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
150
150
  ## 변경 이력 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
+ - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
153
155
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
154
156
  - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
155
157
  - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
156
- - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
157
- - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
150
150
  ## Changelog
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
+ - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
153
155
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
154
156
  - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
155
157
  - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
156
- - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
157
- - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
150
150
  ## 更新日志 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
154
+ - **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
153
155
  - **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
154
156
  - **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
155
157
  - **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
156
- - **[0.4.6]** (2026-06-14) — Width-correct forge cards for CJK/emoji, red borders on failed tool cards, aligned `ooo ralph` monitor HUD, and a per-theme user-card palette.
157
- - **[0.4.5]** (2026-06-14) — First-class filesystem make/remove tools.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.4.9",
3
+ "version": "0.5.1",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -78,7 +78,10 @@ const EXPLICIT_GUIDANCE_FILES = [".agents/oma-config.yaml", ".agents/oma-config.
78
78
 
79
79
  // Module-level cache of the downward scan, keyed by resolved cwd. The scan is
80
80
  // independent of $HOME (it only walks the cwd subtree), so caching by cwd is safe.
81
+ // Bounded LRU: a long-running session that scans many distinct cwds (subagents,
82
+ // worktrees, /view of other trees) must not grow this Map without bound.
81
83
  const workspaceScanCache = new Map<string, WorkspaceScan>();
84
+ const WORKSPACE_SCAN_CACHE_CAP = 32;
82
85
 
83
86
  function segsEqual(a: readonly string[], b: readonly string[]): boolean {
84
87
  if (a.length !== b.length) return false;
@@ -161,8 +164,18 @@ async function scanWorkspaceDownwards(resolvedCwd: string): Promise<WorkspaceSca
161
164
  async function getWorkspaceScan(cwd: string): Promise<WorkspaceScan> {
162
165
  const resolvedCwd = path.resolve(cwd);
163
166
  const cached = workspaceScanCache.get(resolvedCwd);
164
- if (cached) return cached;
167
+ if (cached) {
168
+ // LRU refresh: re-insert so the most-recently-used entry is evicted last.
169
+ workspaceScanCache.delete(resolvedCwd);
170
+ workspaceScanCache.set(resolvedCwd, cached);
171
+ return cached;
172
+ }
165
173
  const scan = await scanWorkspaceDownwards(resolvedCwd);
174
+ // Evict the oldest entry (Map preserves insertion order) once at capacity.
175
+ if (workspaceScanCache.size >= WORKSPACE_SCAN_CACHE_CAP) {
176
+ const oldest = workspaceScanCache.keys().next().value;
177
+ if (oldest !== undefined) workspaceScanCache.delete(oldest);
178
+ }
166
179
  workspaceScanCache.set(resolvedCwd, scan);
167
180
  return scan;
168
181
  }
@@ -233,7 +233,7 @@ type ParsedFile =
233
233
  | { kind: "ok"; config: Config }
234
234
  | { kind: "missing" }
235
235
  | { kind: "bad-json"; warned: boolean }
236
- | { kind: "invalid"; message: string; warned: boolean };
236
+ | { kind: "invalid"; message: string; warned: boolean; rawObject?: unknown };
237
237
  const configReadCache = new Map<string, { mtimeMs: number; size: number; parsed: ParsedFile }>();
238
238
  const CONFIG_CACHE_CAP = 8;
239
239
 
@@ -259,7 +259,9 @@ async function readParsedConfigFile(): Promise<ParsedFile> {
259
259
  const result = parseConfig(raw);
260
260
  parsed = result.ok
261
261
  ? { kind: "ok", config: result.config as Config }
262
- : { kind: "invalid", message: result.message, warned: false };
262
+ // Keep the raw JSON object on a schema-invalid (but JSON-valid) config so
263
+ // readRawGlobalConfig can salvage the credential blocks instead of dropping them.
264
+ : { kind: "invalid", message: result.message, warned: false, rawObject: raw };
263
265
  } catch {
264
266
  parsed = { kind: "bad-json", warned: false };
265
267
  }
@@ -271,6 +273,20 @@ async function readParsedConfigFile(): Promise<ParsedFile> {
271
273
  return parsed;
272
274
  }
273
275
 
276
+ /** Overlay the `oauth` + `providers` credential blocks from a schema-invalid (but
277
+ * JSON-valid) config's raw object onto `base`. A single bad scalar field (e.g. a
278
+ * non-string defaultModel) must never cost the user their stored credentials — neither
279
+ * when resolving them at runtime (readGlobalConfig) nor when persisting (readRawGlobalConfig). */
280
+ function salvageCredentials(base: Config, parsed: ParsedFile): Config {
281
+ if (parsed.kind !== "invalid" || !parsed.rawObject || typeof parsed.rawObject !== "object") return base;
282
+ const ro = parsed.rawObject as Record<string, unknown>;
283
+ if (ro.oauth && typeof ro.oauth === "object") base.oauth = structuredClone(ro.oauth) as Config["oauth"];
284
+ if (ro.providers && typeof ro.providers === "object") {
285
+ base.providers = { ...base.providers, ...(structuredClone(ro.providers) as Config["providers"]) };
286
+ }
287
+ return base;
288
+ }
289
+
274
290
  export async function readGlobalConfig(): Promise<Config> {
275
291
  const parsed = await readParsedConfigFile();
276
292
  if (parsed.kind === "bad-json" || parsed.kind === "invalid") {
@@ -280,7 +296,9 @@ export async function readGlobalConfig(): Promise<Config> {
280
296
  const detail = parsed.kind === "invalid" ? ` is invalid (${parsed.message})` : " is not valid JSON";
281
297
  process.stderr.write(`[jeo] ${globalConfigPath()}${detail}; using environment defaults.\n`);
282
298
  }
283
- return withEnvOverlay(envDefaultConfig());
299
+ // Credential salvage: keep oauth/providers from a JSON-valid-but-schema-invalid
300
+ // config so the runtime stays authenticated (and a later write repairs the file).
301
+ return withEnvOverlay(salvageCredentials(envDefaultConfig(), parsed));
284
302
  }
285
303
  if (parsed.kind === "missing") return withEnvOverlay(envDefaultConfig());
286
304
  return withEnvOverlay(structuredClone(parsed.config));
@@ -307,9 +325,15 @@ export async function saveGlobalConfig(config: Config): Promise<void> {
307
325
  * JEO_*_MODEL role tiers, OLLAMA_HOST/OPENAI_BASE_URL) are never baked into
308
326
  * ~/.jeo/config.json by an unrelated `/agents`/`/roles`/`/model save`. */
309
327
  export async function readRawGlobalConfig(): Promise<Config> {
310
- const clean: Config = { providers: {}, defaultModel: DEFAULT_MODEL, thinkingLevel: "medium" };
311
328
  const parsed = await readParsedConfigFile();
312
- return parsed.kind === "ok" ? structuredClone(parsed.config) : clean;
329
+ if (parsed.kind === "ok") return structuredClone(parsed.config);
330
+ // CREDENTIAL SAFETY: when the on-disk config is JSON-valid but fails schema validation
331
+ // on some unrelated field, salvage the `oauth` + `providers` blocks. Without this, the
332
+ // next saveConfigPatch (e.g. an auto token refresh that patches ONE provider) bases on
333
+ // a clean config and silently wipes every OTHER stored credential — the "OAuth de-authed
334
+ // after a session" bug. The invalid scalar field is dropped (reset to default).
335
+ const clean: Config = { providers: {}, defaultModel: DEFAULT_MODEL, thinkingLevel: "medium" };
336
+ return salvageCredentials(clean, parsed);
313
337
  }
314
338
 
315
339
  /** Merge a patch onto the RAW on-disk config and persist. The `build` callback
@@ -326,17 +350,65 @@ export function getLocalJeoDir(cwd: string = process.cwd()): string {
326
350
  return path.join(cwd, ".jeo");
327
351
  }
328
352
 
353
+ // mtime+size-validated cache for the small per-skill workflow-state JSON. readWorkflowState
354
+ // runs repeatedly (the mutation guard reads before every mutating tool), so re-reading +
355
+ // re-parsing each call is wasteful. CROSS-PROCESS-SAFE: a write by another process bumps
356
+ // mtime/size → forces a fresh read, so the security-sensitive guard never serves a stale
357
+ // lock. Same-process write/clear update or drop the entry directly. LRU-capped.
358
+ const workflowStateCache = new Map<string, { mtimeMs: number; size: number; value: WorkflowState | null }>();
359
+ const WORKFLOW_STATE_CACHE_CAP = 16;
360
+
361
+ function cacheWorkflowState(statePath: string, mtimeMs: number, size: number, value: WorkflowState | null): void {
362
+ workflowStateCache.delete(statePath);
363
+ if (workflowStateCache.size >= WORKFLOW_STATE_CACHE_CAP) {
364
+ const oldest = workflowStateCache.keys().next().value;
365
+ if (oldest !== undefined) workflowStateCache.delete(oldest);
366
+ }
367
+ workflowStateCache.set(statePath, { mtimeMs, size, value });
368
+ }
369
+
370
+ async function loadWorkflowStateFile(statePath: string, strict: boolean): Promise<WorkflowState | null> {
371
+ let st: { mtimeMs: number; size: number };
372
+ try {
373
+ st = await fs.stat(statePath);
374
+ } catch (err) {
375
+ workflowStateCache.delete(statePath);
376
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
377
+ if (strict) throw err;
378
+ return null;
379
+ }
380
+ const hit = workflowStateCache.get(statePath);
381
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) {
382
+ workflowStateCache.delete(statePath); // LRU refresh
383
+ workflowStateCache.set(statePath, hit);
384
+ return hit.value;
385
+ }
386
+ let data: string;
387
+ try {
388
+ data = await fs.readFile(statePath, "utf-8");
389
+ } catch (err) {
390
+ workflowStateCache.delete(statePath);
391
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
392
+ if (strict) throw err;
393
+ return null;
394
+ }
395
+ try {
396
+ const parsed = JSON.parse(data) as WorkflowState;
397
+ cacheWorkflowState(statePath, st.mtimeMs, st.size, parsed);
398
+ return parsed;
399
+ } catch {
400
+ workflowStateCache.delete(statePath);
401
+ if (strict) throw new Error(`workflow state ${statePath} is corrupt (invalid JSON)`);
402
+ return null;
403
+ }
404
+ }
405
+
329
406
  export async function readWorkflowState(
330
407
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
331
408
  cwd: string = process.cwd()
332
409
  ): Promise<WorkflowState | null> {
333
410
  const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
334
- try {
335
- const data = await fs.readFile(statePath, "utf-8");
336
- return JSON.parse(data) as WorkflowState;
337
- } catch {
338
- return null;
339
- }
411
+ return loadWorkflowStateFile(statePath, false);
340
412
  }
341
413
 
342
414
  /**
@@ -349,18 +421,7 @@ export async function readWorkflowStateStrict(
349
421
  cwd: string = process.cwd()
350
422
  ): Promise<WorkflowState | null> {
351
423
  const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
352
- let data: string;
353
- try {
354
- data = await fs.readFile(statePath, "utf-8");
355
- } catch (err) {
356
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
357
- throw err;
358
- }
359
- try {
360
- return JSON.parse(data) as WorkflowState;
361
- } catch {
362
- throw new Error(`workflow state ${statePath} is corrupt (invalid JSON)`);
363
- }
424
+ return loadWorkflowStateFile(statePath, true);
364
425
  }
365
426
 
366
427
  export async function writeWorkflowState(
@@ -382,6 +443,14 @@ export async function writeWorkflowState(
382
443
  await fs.unlink(tmpPath).catch(() => {});
383
444
  throw err;
384
445
  }
446
+ // Cache the just-written state keyed on the new file fingerprint so the next read
447
+ // (often the mutation guard milliseconds later) is served from memory.
448
+ try {
449
+ const st = await fs.stat(statePath);
450
+ cacheWorkflowState(statePath, st.mtimeMs, st.size, state);
451
+ } catch {
452
+ workflowStateCache.delete(statePath);
453
+ }
385
454
  return statePath;
386
455
  }
387
456
 
@@ -393,6 +462,7 @@ export async function clearWorkflowState(
393
462
  try {
394
463
  await fs.unlink(statePath);
395
464
  } catch {}
465
+ workflowStateCache.delete(statePath);
396
466
  }
397
467
 
398
468
  /**
@@ -66,7 +66,7 @@ import { stripMarkdown } from "../tui/components/markdown-text";
66
66
  import { summarizeForgeInvocation } from "../tui/components/forge";
67
67
  import { formatDuration, formatUsage } from "../tui/components/duration";
68
68
 
69
- import { findTool, searchTool } from "../agent/tools";
69
+ import { findTool, searchTool, bashTool } from "../agent/tools";
70
70
  import { loadProjectContext, withProjectContext } from "../agent/context-files";
71
71
  import { maybeCompact, historyTokens } from "../agent/compaction";
72
72
  import * as path from "node:path";
@@ -2998,9 +2998,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2998
2998
  if (pendingImages.length === 0) continue;
2999
2999
  input = "Please look at the attached image(s)."; // image-only submit
3000
3000
  }
3001
+ // gjc-parity shell escape: `!<cmd>` runs the command directly in cmd-mode and
3002
+ // prints its output WITHOUT engaging the agent (history untouched), like a REPL
3003
+ // shell escape. The user is explicitly driving their own shell, so the deep-interview
3004
+ // mutation guard (which gates the AGENT's tools) does not apply here.
3005
+ if (input.startsWith("!")) {
3006
+ const cmd = input.slice(1).trim();
3007
+ if (!cmd) {
3008
+ console.log("Usage: !<shell command> (run a command in cmd-mode; the agent and history are untouched)");
3009
+ continue;
3010
+ }
3011
+ try {
3012
+ const res = await bashTool(cmd, cwd);
3013
+ if (res.output) console.log(res.output);
3014
+ if (!res.success && res.error) console.log(chalk.red(res.error));
3015
+ } catch (err) {
3016
+ console.log(chalk.red(`! command failed: ${(err as Error).message}`));
3017
+ }
3018
+ continue;
3019
+ }
3001
3020
  if (input === "/" || input === "/?" || input === "/help") {
3002
3021
  logLines(formatSlashCommandList(input === "/help" ? "/" : input, skillSlashDetails));
3003
3022
  console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
3023
+ console.log("Shell: !<command> runs a command in cmd-mode directly (agent/history untouched).");
3004
3024
  const tip = getEvolutionTip(history.length, flags.maxSteps > 0 ? flags.maxSteps : initialStepLimit);
3005
3025
  console.log(`\n${chalk.cyan("Evolutionary Tip:")} ${tip}`);
3006
3026
  continue;
@@ -524,6 +524,15 @@ export const DNA_CLAW_ART_GRAND_ASCII: string[] = [
524
524
  " [ D N A . C L A W ] "
525
525
  ];
526
526
 
527
+ // Bounded memo of fully-rendered DNA Claw frames keyed by every input that affects
528
+ // output (grand/unicode/cols/color/colorLevel/phase/frame). The live HUD cycles a
529
+ // FIXED ~60-frame set (3 twists × 20 gradient phases) at ~120ms; without this each
530
+ // recurrence recomputed per-line animatedGradientText (ANSI gradient) from scratch.
531
+ // The memo makes the 2nd+ cycle O(1) lookups, cutting steady-state HUD CPU. LRU-capped.
532
+ const dnaClawMemo = new Map<string, string[]>();
533
+ const DNA_CLAW_MEMO_CAP = 256;
534
+ const EMPTY_DNA_FRAME: string[] = [];
535
+
527
536
  export function renderDnaClaw(opts: {
528
537
  cols?: number;
529
538
  phase?: number;
@@ -537,6 +546,9 @@ export function renderDnaClaw(opts: {
537
546
  * `phase` this animates the forge identity without any frame-count growth. */
538
547
  frame?: number;
539
548
  }): string[] {
549
+ const memoKey = `${opts.grand ? "g" : "c"}|${opts.unicode !== false ? 1 : 0}|${opts.cols ?? -1}|${opts.color !== false ? 1 : 0}|${opts.colorLevel ?? ColorLevel.TrueColor}|${opts.phase ?? 0}|${opts.frame ?? 0}`;
550
+ const memoHit = dnaClawMemo.get(memoKey);
551
+ if (memoHit) return memoHit;
540
552
  const useUnicode = opts.unicode !== false;
541
553
  let source: string[];
542
554
  if (opts.grand) {
@@ -549,7 +561,8 @@ export function renderDnaClaw(opts: {
549
561
  const width = Math.max(0, ...source.map(l => l.length));
550
562
 
551
563
  if (opts.cols !== undefined && opts.cols < width) {
552
- return [];
564
+ dnaClawMemo.set(memoKey, EMPTY_DNA_FRAME);
565
+ return EMPTY_DNA_FRAME;
553
566
  }
554
567
 
555
568
  const phase = opts.phase ?? 0;
@@ -557,13 +570,20 @@ export function renderDnaClaw(opts: {
557
570
  const colorLevel = opts.colorLevel ?? ColorLevel.TrueColor;
558
571
  const palette = DNA_FLOW_PALETTE;
559
572
 
560
- return source.map((line, idx) => {
573
+ const result = source.map((line, idx) => {
561
574
  const padded = line.length < width ? line + " ".repeat(width - line.length) : line;
562
575
  if (!useColor || colorLevel < ColorLevel.TrueColor) {
563
576
  return padded;
564
577
  }
565
578
  return animatedGradientText(padded, palette, phase + idx * 0.07, { colorLevel });
566
579
  });
580
+
581
+ if (dnaClawMemo.size >= DNA_CLAW_MEMO_CAP) {
582
+ const oldest = dnaClawMemo.keys().next().value;
583
+ if (oldest !== undefined) dnaClawMemo.delete(oldest);
584
+ }
585
+ dnaClawMemo.set(memoKey, result);
586
+ return result;
567
587
  }
568
588
 
569
589
  /** The DNA Claw identity palette (emerald → cyan → violet helix flow). Shared by