Skill: luke2day/human-typing-simulator (version 1.0.2)

--- name: human-type description: Type text into a browser like a real human with adjacent-key typos, immediate and deferred corrections, French accent omissions fixed later, thinking pauses, and variable speed over a specified duration. version: 2.0.1 metadata: {"openclaw":{"emoji":"⌨️","requires":{"bins":["node"]}}} --- ## What this skill does `human-type` generates a keystroke event script from a desired final text and plays it back in the focused browser element. The script includes: - **Adjacent-key typos** — wrong chars from QWERTY neighbors - **Immediate fixes** — backspace and retype on the spot - **Deferred fixes** — finish typing the paragraph, then navigate back to fix - **French accent omissions** — type `e` instead of `é`, fix later (French mode) - **Thinking pauses** — random hesitation gaps - **Speed jitter** — each keystroke has independent timing noise - **Seeded reproducibility** — `--seed` gives identical replays The final text produced is **always exactly** the target string. ## Installation check ```bash node "{baseDir}/human-type.js" --version ``` Optional global install: ```bash npm install -g human-type-cli ``` ## Core command ```bash node "{baseDir}/human-type.js" run \ --text "<final text>" \ --duration <seconds> \ [--mistakes <0-100>] \ [--pauses <0-100>] \ [--defer <0-100>] \ [--french] \ [--accent-omit <0-100>] \ [--selector "<css>"] \ [--seed <n>] \ [--dry-run] ``` ## All flags | Flag | Default | Description | |---|---|---| | `--text` | *(required)* | Final text to produce | | `--duration` | `10` | Total time in seconds | | `--mistakes` | `20` | Immediate typo rate % | | `--pauses` | `15` | Pause frequency % | | `--defer` | `35` | % of typos deferred (fixed after paragraph) | | `--french` | off | Enable French accent omission mode | | `--accent-omit` | `60` | % of accented chars omitted initially (French mode) | | `--selector` | — | CSS selector to click/focus before typing | | `--delay-start` | `500` | Ms to wait before first keystroke | | `--seed` | random | Fix RNG for reproducible output | | `--dry-run` | off | Print script without typing | | `--cdp-url` | `http://127.0.0.1:18800` | Chrome DevTools Protocol URL | ## Workflow ```bash # 1. Start and use the OpenClaw browser tool # Never use macOS `open` or any shell browser launch command for this workflow. openclaw browser start # 2. Navigate to target page openclaw browser open https://example.com # 3. Get element refs openclaw browser snapshot --interactive # 4. Type like a human into a specific element # Prefer --selector so the CLI can focus the field itself. # 5. Type like a human node "{baseDir}/human-type.js" run \ --text "Hello, I'd like to ask about your services." \ --duration 10 \ --mistakes 20 \ --defer 40 \ --selector "textarea" # 6. Verify openclaw browser snapshot --interactive ``` ## Common patterns ### Natural English paragraph ```bash node "{baseDir}/human-type.js" run \ --text "The meeting went well. I think we aligned on the key points." \ --duration 15 \ --mistakes 25 \ --defer 40 \ --pauses 20 ``` ### French text (accent omissions fixed later) ```bash node "{baseDir}/human-type.js" run \ --text "Être ou ne pas être, voilà la question." \ --duration 15 \ --french \ --accent-omit 70 \ --defer 30 ``` ### Reproducible (for testing / demo recording) ```bash node "{baseDir}/human-type.js" run \ --text "Exact same every time." \ --duration 8 \ --seed 42 ``` ### Preview before running ```bash node "{baseDir}/human-type.js" run --text "Check this first" --duration 6 --dry-run ``` ## Save and replay a script ```bash # Generate and save node "{baseDir}/human-type.js" generate \ --text "Reuse this exact sequence." \ --duration 8 --seed 42 \ --output ./my-script.json # Replay later node "{baseDir}/human-type.js" play ./my-script.json --selector "#comment" # Inspect stats node "{baseDir}/human-type.js" inspect ./my-script.json ``` ## WPM reference | Style | WPM | Duration for 100 chars | |---|---|---| | Fast typist | 70+ | ~9s | | Average adult | 50 | ~12s | | Slow / careful | 30 | ~20s | | Hunt-and-peck | 15 | ~40s | ## Rules - Always use the OpenClaw browser tool for browser startup, navigation, and inspection before typing. - Never use macOS `open`, shell browser launch commands, or any non-OpenClaw browser startup path for this workflow. - If the OpenClaw browser is not running yet, start it with `openclaw browser start` before opening the page. - In OpenClaw, prefer `node "{baseDir}/human-type.js"` so the workspace skill works without any global install. - Always prefer `--selector` over clicking manually before running because the CLI works directly through CDP, not OpenClaw ref IDs. - Use `--dry-run` first when the duration or behavior is uncertain. - For multi-field forms, call `human-type run` once per field with the appropriate `--selector`. - For French text, `--french` without `--accent-omit` uses the 60% default — adjust if you want more or fewer accent omissions. - `--defer 0` means all typos are fixed immediately (backspace-and-retype). `--defer 100` means all typos are left in place and fixed after the paragraph. - The browser must already be open in OpenClaw before running. By default the CLI connects to `http://127.0.0.1:18800`, which matches the standard `openclaw` browser profile. - `--seed` values are fully deterministic — the same seed + same text + same options always produces an identical keystroke sequence. ## Event format (JSON script) Each event in the generated script has: ```json { "type": "type", "ch": "H", "time": 0, "duration": 195 } { "type": "delete", "time": 195, "duration": 140 } { "type": "pause", "time": 335, "duration": 480 } { "type": "arrow", "dir": "Left", "n": 4, "time": 815, "duration": 320 } { "type": "select-back", "n": 1, "time": 1135,"duration": 110 } { "type": "select-replace", "ch": "é", "time": 1245,"duration": 200 } ``` `time` is ms offset from start. `duration` is gap until next event.

path: _meta.json
content: ownerId: kn7e69chmnzh0zb1pxmcfjtv3583ggwh
slug: human-typing-simulator
version: 2.0.1
publishedAt: 1774373327415

path: engine.js
content: /**
 * human-type engine v2
 * Generates a keystroke event script from a target string.
 *
 * Event types:
 *   { type: 'type',           ch, time, duration }   — press a key
 *   { type: 'delete',             time, duration }   — backspace
 *   { type: 'pause',              time, duration }   — wait (no key)
 *   { type: 'arrow',       dir, n, time, duration }  — move cursor (dir: 'Left'|'Right'|'Home'|'End')
 *   { type: 'select-back',     n, time, duration }   — Shift+Left × n (select n chars behind cursor)
 *   { type: 'select-replace', ch, time, duration }   — type ch, replacing selection
 *
 * Deferred-fix flow:
 *   1. While typing, some mistakes are NOT immediately corrected.
 *      The wrong char is typed and the script keeps going.
 *   2. After the paragraph, one or more fix passes navigate back,
 *      select each wrong char, and replace it with the correct one.
 *   3. Cursor returns to end.
 *
 * French accent omissions:
 *   Accented chars (é, è, ê, à, â, ù, û, î, ô, ç, œ, æ …) are typed as
 *   their base ASCII equivalent and added to the deferred list, simulating
 *   a typist who forgets accents in flow and goes back to correct them.
 */

// ─── QWERTY adjacent-key map ──────────────────────────────────────────────────

const NEAR_KEYS = {
  a: 'sqwz',   b: 'vghn',   c: 'xdfv',   d: 'serfxc', e: 'wsdr34',
  f: 'drtgvc', g: 'ftyh',   h: 'gyujbn', i: 'ujko89', j: 'huikm',
  k: 'ijol',   l: 'kop',    m: 'njk',    n: 'bhjm',   o: 'iklp90',
  p: 'ol0',    q: 'wa',     r: 'edfgt45',s: 'qazwxcd',t: 'rfghy56',
  u: 'yhji78', v: 'cfgb',   w: 'qase23', x: 'zsdc',   y: 'tghu67',
  z: 'asx',
  '1': '2q',   '2': '1qw3', '3': '2we4', '4': '3er5', '5': '4rt6',
  '6': '5ty7', '7': '6yu8', '8': '7ui9', '9': '8io0', '0': '9op',
  ' ': ' '
};

// ─── French accent map ────────────────────────────────────────────────────────

const ACCENT_TO_BASE = {
  é: 'e', è: 'e', ê: 'e', ë: 'e',
  à: 'a', â: 'a', ä: 'a',
  ù: 'u', û: 'u', ü: 'u',
  î: 'i', ï: 'i',
  ô: 'o', ö: 'o',
  ç: 'c',
  œ: 'o', æ: 'a',
  É: 'E', È: 'E', Ê: 'E', Ë: 'E',
  À: 'A', Â: 'A', Ä: 'A',
  Ù: 'U', Û: 'U', Ü: 'U',
  Î: 'I', Ï: 'I',
  Ô: 'O', Ö: 'O',
  Ç: 'C',
  Œ: 'O', Æ: 'A',
};

// ─── Helpers ──────────────────────────────────────────────────────────────────

function makeRng(seed) {
  let s = seed >>> 0;
  return function () {
    s |= 0; s = s + 0x6D2B79F5 | 0;
    let t = Math.imul(s ^ s >>> 15, 1 | s);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}

function nearMiss(ch, rng) {
  const lower = ch.toLowerCase();
  const neighbors = NEAR_KEYS[lower];
  if (!neighbors) return ch;
  const pick = neighbors[Math.floor(rng() * neighbors.length)];
  return ch === ch.toUpperCase() && ch !== ch.toLowerCase()
    ? pick.toUpperCase()
    : pick;
}

// ─── Segment builder ──────────────────────────────────────────────────────────

/**
 * Build logical action segments before timing is applied.
 *
 * @param {string}  text
 * @param {number}  mistakeRate    0–1, immediate typo rate
 * @param {number}  pauseRate      0–1, pause frequency
 * @param {number}  deferRate      0–1, fraction of mistakes deferred to later
 * @param {number}  accentOmitRate 0–1, fraction of accented chars omitted (French mode)
 * @param {boolean} french         enable accent omission logic
 * @param {object}  rng            seeded RNG
 */
function buildSegments(text, mistakeRate, pauseRate, deferRate, accentOmitRate, french, rng) {
  const segments = [];

  // deferred[]: errors not immediately corrected.
  // outputPos: 0-based index in the running output buffer where the wrong char sits.
  const deferred = [];
  let outLen = 0; // simulated output buffer length (cursor always at end during typing)

  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    const isAccented = french && ACCENT_TO_BASE[ch] !== undefined;
    const canMistake = /[a-zA-ZÀ-ÿ0-9]/.test(ch);

    // Optional pre-char pause
    if (segments.length > 0 && rng() < pauseRate) {
      segments.push({ kind: 'pause' });
    }

    // ── French accent omission ──────────────────────────────────────────────
    if (isAccented && rng() < accentOmitRate) {
      const baseCh = ACCENT_TO_BASE[ch];
      segments.push({ kind: 'type', ch: baseCh });
      deferred.push({ outputPos: outLen, correct: ch, typed: baseCh, reason: 'accent' });
      outLen++;
      continue;
    }

    // ── Typo ────────────────────────────────────────────────────────────────
    if (canMistake && !isAccented && rng() < mistakeRate) {
      const shouldDefer = rng() < deferRate;

      if (shouldDefer) {
        // Type wrong char, keep going, fix later
        const wrongCh = nearMiss(ch, rng);
        segments.push({ kind: 'type', ch: wrongCh });
        deferred.push({ outputPos: outLen, correct: ch, typed: wrongCh, reason: 'typo' });
        outLen++;
        // Do NOT type the correct char here — the deferred fix will overwrite it
        continue;
      } else {
        // Immediate fix: type N wrong chars → pause maybe → delete all → type correct
        const mistakeLen = Math.floor(rng() * 3) + 1;
        for (let m = 0; m < mistakeLen; m++) {
          segments.push({ kind: 'type', ch: nearMiss(ch, rng) });
          outLen++;
        }
        if (rng() < 0.4) segments.push({ kind: 'pause' });
        for (let m = 0; m < mistakeLen; m++) {
          segments.push({ kind: 'delete' });
          outLen--;
        }
      }
    }

    segments.push({ kind: 'type', ch });
    outLen++;
  }

  // ── Deferred fixup pass ──────────────────────────────────────────────────
  // Cursor is at end (outLen). Navigate back to each mistake, select-replace, return.

  if (deferred.length > 0) {
    // Re-read pause — like the human finishes typing, then reads the paragraph
    segments.push({ kind: 'pause' });
    segments.push({ kind: 'pause' });

    // Sort left to right so we sweep in one direction (fewer total arrow moves)
    deferred.sort((a, b) => a.outputPos - b.outputPos);

    let cursorPos = outLen;

    for (const fix of deferred) {
      // Move cursor to just AFTER the wrong char: index fix.outputPos + 1
      const dest = fix.outputPos + 1;
      const delta = cursorPos - dest;

      if (delta !== 0) {
        // Slight hesitation before navigating (human eyeing the position)
        if (rng() < 0.6) segments.push({ kind: 'pause' });

        if (delta > 0) {
          segments.push({ kind: 'arrow', dir: 'Left', n: delta });
        } else {
          segments.push({ kind: 'arrow', dir: 'Right', n: -delta });
        }
        cursorPos = dest;
      }

      // Select the 1 wrong char to the left of cursor, then type the correct one
      segments.push({ kind: 'select-back', n: 1 });
      segments.push({ kind: 'select-replace', ch: fix.correct });
      // cursorPos stays at dest (correct char now replaces wrong one, same length)
    }

    // Return cursor to end
    const toEnd = outLen - cursorPos;
    if (toEnd > 0) {
      segments.push({ kind: 'pause' });
      segments.push({ kind: 'arrow', dir: 'Right', n: toEnd });
    }
  }

  return segments;
}

// ─── Timing assignment ────────────────────────────────────────────────────────

function segWeight(seg) {
  if (seg.kind === 'pause')          return 0;
  if (seg.kind === 'arrow')          return Math.max(1, seg.n) * 0.5;
  if (seg.kind === 'select-back')    return Math.max(1, seg.n) * 0.5;
  if (seg.kind === 'select-replace') return 1.3; // deliberate, slightly slow
  return 1; // type / delete
}

function assignTimings(segments, durationMs, rng) {
  const keySegs   = segments.filter(s => s.kind !== 'pause');
  const pauseSegs = segments.filter(s => s.kind === 'pause');

  const totalWeight = keySegs.reduce((sum, s) => sum + segWeight(s), 0);
  const pauseBudget = pauseSegs.length > 0 ? durationMs * 0.28 : 0;
  const keyBudget   = durationMs - pauseBudget;
  const msPerWeight = totalWeight > 0 ? keyBudget / totalWeight : 0;
  const avgPauseMs  = pauseSegs.length > 0 ? pauseBudget / pauseSegs.length : 0;

  const events = [];
  let time = 0;

  for (const seg of segments) {
    let duration;

    if (seg.kind === 'pause') {
      duration = Math.round(avgPauseMs * (0.4 + rng() * 1.6));
    } else {
      const base = msPerWeight * segWeight(seg);
      duration = Math.round(base * (0.35 + rng() * 1.5));
      duration = Math.max(duration, 25);
    }

    const ev = { type: seg.kind, time: Math.round(time), duration };
    if (seg.ch  !== undefined) ev.ch  = seg.ch;
    if (seg.dir !== undefined) ev.dir = seg.dir;
    if (seg.n   !== undefined) ev.n   = seg.n;

    events.push(ev);
    time += duration;
  }

  return events;
}

// ─── Simulator (for tests and dry-run verification) ───────────────────────────

/**
 * Replay events against a virtual buffer and return the final text.
 * Returns { ok, finalText } where ok = (finalText === targetText).
 */
function simulate(events, targetText) {
  let buf = '';
  let cursor = 0;
  let selStart = null;

  for (const ev of events) {
    switch (ev.type) {
      case 'type':
        buf = buf.slice(0, cursor) + ev.ch + buf.slice(cursor);
        cursor++;
        selStart = null;
        break;
      case 'delete':
        if (cursor > 0) {
          buf = buf.slice(0, cursor - 1) + buf.slice(cursor);
          cursor--;
        }
        selStart = null;
        break;
      case 'arrow':
        if (ev.dir === 'Left')  cursor = Math.max(0,          cursor - ev.n);
        if (ev.dir === 'Right') cursor = Math.min(buf.length, cursor + ev.n);
        if (ev.dir === 'Home')  cursor = 0;
        if (ev.dir === 'End')   cursor = buf.length;
        selStart = null;
        break;
      case 'select-back':
        selStart = Math.max(0, cursor - ev.n);
        break;
      case 'select-replace':
        if (selStart !== null) {
          buf    = buf.slice(0, selStart) + ev.ch + buf.slice(cursor);
          cursor = selStart + ev.ch.length;
          selStart = null;
        } else {
          buf = buf.slice(0, cursor) + ev.ch + buf.slice(cursor);
          cursor += ev.ch.length;
        }
        break;
      case 'pause':
        break;
    }
  }

  return { ok: buf === targetText, finalText: buf };
}

// ─── Public API ───────────────────────────────────────────────────────────────

/**
 * Generate a keystroke script.
 *
 * @param {string} text
 * @param {number} durationSec
 * @param {object} opts
 * @param {number}  [opts.mistakeRate=0.20]     immediate typo rate (0–1)
 * @param {number}  [opts.pauseRate=0.15]        pause frequency (0–1)
 * @param {number}  [opts.deferRate=0.35]        fraction of mistakes deferred (0–1)
 * @param {number}  [opts.accentOmitRate=0.60]   fraction of accented chars omitted (0–1)
 * @param {boolean} [opts.french=false]          enable French accent omission mode
 * @param {number}  [opts.seed]                  RNG seed for reproducibility
 */
function generateScript(text, durationSec, opts = {}) {
  const {
    mistakeRate    = 0.20,
    pauseRate      = 0.15,
    deferRate      = 0.35,
    accentOmitRate = 0.60,
    french         = false,
    seed           = Math.floor(Math.random() * 0xFFFFFFFF),
  } = opts;

  if (!text || text.length === 0) throw new Error('text must not be empty');
  const durationMs = Math.max(durationSec * 1000, 2000);

  const rngSegments = makeRng(seed);
  const rngTiming   = makeRng(seed ^ 0xDEADBEEF);

  const segments = buildSegments(
    text, mistakeRate, pauseRate, deferRate, accentOmitRate, french, rngSegments
  );
  const events = assignTimings(segments, durationMs, rngTiming);

  return { events, seed };
}

/**
 * Stats summary for a generated script.
 */
function inspectScript(events, targetText) {
  const types         = events.filter(e => e.type === 'type').length;
  const deletes       = events.filter(e => e.type === 'delete').length;
  const pauses        = events.filter(e => e.type === 'pause').length;
  const deferredFixes = events.filter(e => e.type === 'select-replace').length;
  const arrows        = events.filter(e => e.type === 'arrow' || e.type === 'select-back').length;
  const totalMs       = events.length > 0
    ? events[events.length - 1].time + events[events.length - 1].duration
    : 0;
  const wordCount = targetText ? targetText.trim().split(/\s+/).length : 0;
  const wpm = wordCount > 0 && totalMs > 0
    ? Math.round(wordCount / (totalMs / 60000))
    : 0;

  return {
    events: events.length,
    keystrokes: types,
    immediateFixes: deletes,
    deferredFixes,
    arrowMoves: arrows,
    pauses,
    totalMs,
    totalSec: (totalMs / 1000).toFixed(1),
    avgWpm: wpm,
  };
}

module.exports = { generateScript, inspectScript, simulate, nearMiss, ACCENT_TO_BASE };


path: engine.test.js
content: /**
 * human-type engine v2 tests
 * Run: node engine.test.js
 */

const { generateScript, inspectScript, simulate, ACCENT_TO_BASE } = require('./engine');

let passed = 0;
let failed = 0;

function assert(condition, label) {
  if (condition) {
    process.stdout.write(`  \x1b[32m✓\x1b[0m ${label}\n`);
    passed++;
  } else {
    process.stderr.write(`  \x1b[31m✗\x1b[0m ${label}\n`);
    failed++;
  }
}

function assertEqual(a, b, label) {
  assert(a === b, `${label} (got: ${JSON.stringify(a)}, want: ${JSON.stringify(b)})`);
}

function section(name) {
  process.stdout.write(`\n${name}\n`);
}

// ─── Core correctness ─────────────────────────────────────────────────────────

section('Core correctness');

{
  // No mistakes, no defers → clean pass, final text must match
  const { events } = generateScript('hello world', 5, {
    mistakeRate: 0, pauseRate: 0, deferRate: 0, seed: 1,
  });
  const { ok, finalText } = simulate(events, 'hello world');
  assert(ok, 'no-mistake script produces correct final text');
  assertEqual(finalText, 'hello world', 'final text exact match');
}

{
  // High mistake + defer rate — final text must still be correct
  const target = 'The quick brown fox jumps over the lazy dog.';
  const { events } = generateScript(target, 15, {
    mistakeRate: 0.5, pauseRate: 0.2, deferRate: 0.6, seed: 42,
  });
  const { ok, finalText } = simulate(events, target);
  assert(ok, 'high-mistake + high-defer script produces correct final text');
}

{
  // Multiple runs with different texts
  const cases = [
    'Short.',
    'A slightly longer sentence with punctuation!',
    'Numbers like 42 and 100 should work too.',
    'UPPERCASE AND lowercase mixed.',
  ];
  let allOk = true;
  for (const t of cases) {
    const { events } = generateScript(t, 8, { mistakeRate: 0.3, deferRate: 0.4, seed: 7 });
    const { ok } = simulate(events, t);
    if (!ok) allOk = false;
  }
  assert(allOk, 'correct final text for varied inputs');
}

// ─── Seeded reproducibility ───────────────────────────────────────────────────

section('Seeded reproducibility');

{
  const opts = { mistakeRate: 0.3, pauseRate: 0.2, deferRate: 0.4, seed: 1234 };
  const { events: a } = generateScript('reproducible text', 8, opts);
  const { events: b } = generateScript('reproducible text', 8, opts);
  assert(JSON.stringify(a) === JSON.stringify(b), 'same seed → identical scripts');
}

{
  const base = { mistakeRate: 0.3, pauseRate: 0.2, deferRate: 0.4 };
  const { events: a } = generateScript('same text', 8, { ...base, seed: 1 });
  const { events: b } = generateScript('same text', 8, { ...base, seed: 2 });
  assert(JSON.stringify(a) !== JSON.stringify(b), 'different seeds → different scripts');
}

// ─── Timing ───────────────────────────────────────────────────────────────────

section('Timing');

{
  const { events } = generateScript('timing check', 10, { seed: 5 });
  let ok = true;
  for (let i = 1; i < events.length; i++) {
    if (events[i].time < events[i - 1].time) { ok = false; break; }
  }
  assert(ok, 'event times are non-decreasing');
}

{
  // Total duration within 35% of requested (deferred fixups add overhead)
  const dur = 10;
  const { events } = generateScript('duration accuracy test string', dur, {
    mistakeRate: 0.3, deferRate: 0.5, seed: 9,
  });
  const totalMs = events[events.length - 1].time + events[events.length - 1].duration;
  const ratio = totalMs / (dur * 1000);
  assert(ratio > 0.65 && ratio < 1.45, `total duration within ±35% of target (ratio: ${ratio.toFixed(2)})`);
}

// ─── Deferred fixes ───────────────────────────────────────────────────────────

section('Deferred fixes');

{
  // With deferRate=1, ALL mistakes should be deferred (no immediate deletes in body)
  const { events } = generateScript('deferred only', 10, {
    mistakeRate: 0.8, pauseRate: 0, deferRate: 1.0, seed: 11,
  });
  // Immediate deletes happen only as part of immediate-fix sequences.
  // With deferRate=1, the only deletes should be zero.
  const deletes = events.filter(e => e.type === 'delete');
  assert(deletes.length === 0, 'deferRate=1 produces no immediate backspaces');
}

{
  // With deferRate=0, ALL mistakes should be immediately fixed (no select-replace)
  const { events } = generateScript('immediate only here', 10, {
    mistakeRate: 0.8, pauseRate: 0, deferRate: 0, seed: 22,
  });
  const deferred = events.filter(e => e.type === 'select-replace');
  assert(deferred.length === 0, 'deferRate=0 produces no deferred select-replace events');
}

{
  // With high deferRate, expect select-replace events
  const { events } = generateScript('testing deferred corrections now', 12, {
    mistakeRate: 0.6, deferRate: 0.9, seed: 33,
  });
  const deferred = events.filter(e => e.type === 'select-replace');
  assert(deferred.length > 0, 'high deferRate produces select-replace events');
}

{
  // Arrow events should appear when there are deferred fixes
  const { events } = generateScript('navigating back to fix', 12, {
    mistakeRate: 0.6, deferRate: 0.9, seed: 44,
  });
  const arrows = events.filter(e => e.type === 'arrow');
  const selectBacks = events.filter(e => e.type === 'select-back');
  assert(arrows.length > 0 || selectBacks.length > 0, 'deferred fixes produce cursor navigation events');
}

{
  // Deferred fixes must happen AFTER all typing is done
  // i.e., all select-replace events come after the last 'type' in the main body
  const target = 'deferred ordering test';
  const { events } = generateScript(target, 12, {
    mistakeRate: 0.7, deferRate: 1.0, seed: 55,
  });
  const lastTypeIdx = events.reduce((last, ev, i) =>
    ev.type === 'type' && i > last ? i : last, -1);
  const firstArrowIdx = events.findIndex(e => e.type === 'arrow');
  if (firstArrowIdx !== -1) {
    assert(firstArrowIdx > lastTypeIdx, 'arrow navigation comes after all main typing');
  } else {
    // No mistakes happened (RNG might not trigger), skip
    assert(true, 'no deferred fixes generated (RNG miss), skip ordering check');
  }
}

// ─── French accent mode ───────────────────────────────────────────────────────

section('French accent mode');

{
  // Accent map exists and maps correctly
  assertEqual(ACCENT_TO_BASE['é'], 'e', 'é maps to e');
  assertEqual(ACCENT_TO_BASE['ç'], 'c', 'ç maps to c');
  assertEqual(ACCENT_TO_BASE['à'], 'a', 'à maps to a');
  assertEqual(ACCENT_TO_BASE['î'], 'i', 'î maps to i');
  assertEqual(ACCENT_TO_BASE['ô'], 'o', 'ô maps to o');
  assertEqual(ACCENT_TO_BASE['É'], 'E', 'É (uppercase) maps to E');
}

{
  // French mode: final text is correct despite accent omissions
  const target = 'Être ou ne pas être, voilà la question.';
  const { events } = generateScript(target, 15, {
    french: true, accentOmitRate: 1.0,
    mistakeRate: 0, pauseRate: 0, deferRate: 1.0, seed: 66,
  });
  const { ok, finalText } = simulate(events, target);
  assert(ok, 'French text with 100% accent omission still produces correct final text');
}

{
  // French mode with accentOmitRate=0: no accent omissions, no deferred accents
  const target = 'café et thé';
  const { events } = generateScript(target, 8, {
    french: true, accentOmitRate: 0,
    mistakeRate: 0, pauseRate: 0, deferRate: 0, seed: 77,
  });
  const deferred = events.filter(e => e.type === 'select-replace');
  assert(deferred.length === 0, 'accentOmitRate=0 produces no deferred accent fixes');
  const { ok } = simulate(events, target);
  assert(ok, 'French text with accentOmitRate=0 produces correct final text');
}

{
  // French mode: accent omissions show up as base chars in the event stream
  const target = 'éléphant';
  const { events } = generateScript(target, 8, {
    french: true, accentOmitRate: 1.0,
    mistakeRate: 0, pauseRate: 0, deferRate: 1.0, seed: 88,
  });
  // The 'type' events in the forward pass should contain 'e' not 'é'
  const typeEvents = events.filter(e => e.type === 'type');
  // First few type chars: é→e, l, é→e, p, h, a, n, t
  const hasBaseE = typeEvents.some(e => e.ch === 'e');
  assert(hasBaseE, 'French mode types base char (e) instead of accented char (é)');
  // And select-replace events should contain the actual accented chars
  const replaceEvents = events.filter(e => e.type === 'select-replace');
  const hasAccentedE = replaceEvents.some(e => e.ch === 'é');
  assert(hasAccentedE, 'French deferred fix restores accented char (é)');
}

{
  // Non-French mode: accented chars are typed directly (no omissions)
  const target = 'café';
  const { events } = generateScript(target, 5, {
    french: false, mistakeRate: 0, pauseRate: 0, deferRate: 0, seed: 99,
  });
  const typeEvents = events.filter(e => e.type === 'type');
  const typedStr = typeEvents.map(e => e.ch).join('');
  assertEqual(typedStr, 'café', 'non-French mode types accented chars directly');
}

// ─── Edge cases ───────────────────────────────────────────────────────────────

section('Edge cases');

{
  let threw = false;
  try { generateScript('', 5); } catch (_) { threw = true; }
  assert(threw, 'empty text throws');
}

{
  // Single char
  const { events } = generateScript('x', 2, { mistakeRate: 0, seed: 1 });
  const { ok } = simulate(events, 'x');
  assert(ok, 'single char produces correct result');
}

{
  // Very short duration (clamped to 2s)
  const { events } = generateScript('short', 0.1, { seed: 1 });
  assert(events.length > 0, 'sub-minimum duration still generates events');
}

{
  // Inspect stats
  const { events } = generateScript('stats check', 8, {
    mistakeRate: 0.3, deferRate: 0.5, seed: 100,
  });
  const stats = inspectScript(events, 'stats check');
  assert(typeof stats.events === 'number' && stats.events > 0, 'inspect: events count');
  assert(typeof stats.avgWpm === 'number' && stats.avgWpm > 0, 'inspect: wpm');
  assert(typeof stats.deferredFixes === 'number', 'inspect: deferredFixes field exists');
  assert(typeof stats.arrowMoves === 'number', 'inspect: arrowMoves field exists');
}

// ─── Summary ─────────────────────────────────────────────────────────────────

const total = passed + failed;
process.stdout.write(
  `\n${total} tests: \x1b[32m${passed} passed\x1b[0m, \x1b[31m${failed} failed\x1b[0m\n\n`
);
if (failed > 0) process.exit(1);


path: human-type.js
content: #!/usr/bin/env node
/**
 * human-type CLI
 * Usage: human-type <command> [options]
 */

const fs   = require('fs');
const path = require('path');
const { generateScript, inspectScript } = require('./engine');

const VERSION = '2.0.1';

// ─── Minimal arg parser ──────────────────────────────────────────────────────

function parseArgs(argv) {
  const args = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a.startsWith('--')) {
      const key = a.slice(2);
      const next = argv[i + 1];
      if (next && !next.startsWith('--')) {
        args[key] = next;
        i++;
      } else {
        args[key] = true;
      }
    } else {
      args._.push(a);
    }
  }
  return args;
}

// ─── Helpers ─────────────────────────────────────────────────────────────────

function die(msg) {
  process.stderr.write(`\x1b[31merror:\x1b[0m ${msg}\n`);
  process.exit(1);
}

function info(msg) {
  process.stdout.write(msg + '\n');
}

function pct(n) {
  return Math.round(n * 100);
}

// ─── Commands ─────────────────────────────────────────────────────────────────

async function cmdRun(args) {
  const text = args['text'];
  if (!text) die('--text is required');

  const duration      = parseFloat(args['duration']     ?? '10');
  const mistakeRate   = parseInt(args['mistakes']       ?? '20') / 100;
  const pauseRate     = parseInt(args['pauses']         ?? '15') / 100;
  const deferRate     = parseInt(args['defer']          ?? '35') / 100;
  const accentOmitRate= parseInt(args['accent-omit']   ?? '60') / 100;
  const french        = !!args['french'];
  const seed          = args['seed'] ? parseInt(args['seed']) : undefined;
  const selector      = args['selector'];
  const delayStart    = parseInt(args['delay-start']    ?? '500');
  const dryRun        = !!args['dry-run'];
  const cdpUrl        = args['cdp-url'] ?? process.env.OPENCLAW_CDP_URL;

  if (duration < 2) die('--duration must be at least 2 seconds');

  const modeStr = french ? ', french accent mode' : '';
  info(`\x1b[2mGenerating script for ${text.length} chars over ${duration}s ` +
       `(mistakes ${pct(mistakeRate)}%, pauses ${pct(pauseRate)}%, defer ${pct(deferRate)}%${modeStr})…\x1b[0m`);

  const { events, seed: usedSeed } = generateScript(text, duration, {
    mistakeRate, pauseRate, deferRate, accentOmitRate, french, seed,
  });

  const stats = inspectScript(events, text);
  info(`\x1b[2mScript: ${stats.events} events, ${stats.immediateFixes} immediate fixes, ` +
       `${stats.deferredFixes} deferred fixes, ${stats.pauses} pauses, ~${stats.avgWpm} WPM  [seed: ${usedSeed}]\x1b[0m`);

  if (dryRun) {
    info('\nDry run — script (first 30 events):');
    info(JSON.stringify(events.slice(0, 30), null, 2));
    if (events.length > 30) info(`  … and ${events.length - 30} more events`);
    return;
  }

  const { playScript } = require('./player');
  info(`\x1b[32mTyping…\x1b[0m`);
  await playScript(events, { selector, delayStart, cdpUrl });
  info(`\x1b[32mDone.\x1b[0m`);
}

async function cmdGenerate(args) {
  const text = args['text'];
  if (!text) die('--text is required');

  const duration      = parseFloat(args['duration']     ?? '10');
  const mistakeRate   = parseInt(args['mistakes']       ?? '20') / 100;
  const pauseRate     = parseInt(args['pauses']         ?? '15') / 100;
  const deferRate     = parseInt(args['defer']          ?? '35') / 100;
  const accentOmitRate= parseInt(args['accent-omit']   ?? '60') / 100;
  const french        = !!args['french'];
  const seed          = args['seed'] ? parseInt(args['seed']) : undefined;
  const output        = args['output'] ?? './human-type-script.json';

  const { events, seed: usedSeed } = generateScript(text, duration, {
    mistakeRate, pauseRate, deferRate, accentOmitRate, french, seed,
  });

  const payload = {
    meta: {
      targetText: text,
      duration,
      mistakeRate: pct(mistakeRate),
      pauseRate:   pct(pauseRate),
      deferRate:   pct(deferRate),
      accentOmitRate: pct(accentOmitRate),
      french,
      seed:        usedSeed,
      generatedAt: new Date().toISOString(),
      ...inspectScript(events, text),
    },
    events,
  };

  fs.writeFileSync(path.resolve(output), JSON.stringify(payload, null, 2));
  info(`\x1b[32mScript saved to ${output}  (${events.length} events, seed: ${usedSeed})\x1b[0m`);
}

async function cmdPlay(args) {
  const file = args._[0] ?? args['file'];
  if (!file) die('Usage: human-type play <script.json> [--selector <css>]');

  const raw = fs.readFileSync(path.resolve(file), 'utf8');
  const data = JSON.parse(raw);

  // Support both { events: [...] } and bare array
  const events   = Array.isArray(data) ? data : data.events;
  const selector = args['selector'];
  const cdpUrl   = args['cdp-url'] ?? process.env.OPENCLAW_CDP_URL;

  if (!events?.length) die('Script file contains no events');

  const stats = inspectScript(events, data?.meta?.targetText ?? '');
  info(`\x1b[2mReplaying ${stats.events} events over ~${stats.totalSec}s…\x1b[0m`);

  const { playScript } = require('./player');
  await playScript(events, { selector, cdpUrl });
  info(`\x1b[32mDone.\x1b[0m`);
}

function cmdInspect(args) {
  const file = args._[0] ?? args['file'];
  if (!file) die('Usage: human-type inspect <script.json>');

  const raw  = fs.readFileSync(path.resolve(file), 'utf8');
  const data = JSON.parse(raw);
  const events = Array.isArray(data) ? data : data.events;
  const meta   = data?.meta ?? {};

  if (!events?.length) die('Script file contains no events');

  const stats = inspectScript(events, meta.targetText ?? '');

  info('');
  if (meta.targetText) info(`  Target text    "${meta.targetText}"`);
  info(`  Events         ${stats.events}`);
  info(`  Keystrokes     ${stats.keystrokes}`);
  info(`  Immediate fixes ${stats.immediateFixes}`);
  info(`  Deferred fixes  ${stats.deferredFixes}`);
  info(`  Arrow moves     ${stats.arrowMoves}`);
  info(`  Pauses         ${stats.pauses}`);
  info(`  Total time     ${stats.totalSec}s`);
  info(`  Avg WPM        ${stats.avgWpm}`);
  if (meta.seed) info(`  Seed           ${meta.seed}`);
  if (meta.french) info(`  French mode    yes (accent-omit ${meta.accentOmitRate ?? 60}%)`);
  info('');
}

// ─── Main ─────────────────────────────────────────────────────────────────────

async function main() {
  const argv = process.argv.slice(2);
  const args = parseArgs(argv);
  const cmd  = args._[0];

  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
    info(`human-type v${VERSION}

Usage:
  human-type run      --text <string> [options]
  human-type generate --text <string> [options] [--output <file>]
  human-type play     <script.json>  [--selector <css>] [--cdp-url <url>]
  human-type inspect  <script.json>

Options:
  --text          Final desired text (required for run/generate)
  --duration      Total typing time in seconds (default: 10)
  --mistakes      Immediate typo rate 0-100% (default: 20)
  --pauses        Pause frequency 0-100% (default: 15)
  --defer         Fraction of typos deferred for later correction 0-100% (default: 35)
  --french        Enable French accent omission mode (flag, no value needed)
  --accent-omit   Fraction of accented chars omitted and fixed later 0-100% (default: 60)
  --selector      CSS selector to click/focus before typing
  --delay-start   Ms to wait before first keystroke (default: 500)
  --seed          Fix RNG seed for reproducible output
  --dry-run       Print script without typing
  --output        Output file path for generate command (default: ./human-type-script.json)
  --cdp-url       Chrome DevTools Protocol URL (default: http://127.0.0.1:18800)

Examples:
  human-type run --text "Hello world" --duration 8 --mistakes 25 --defer 40
  human-type run --text "Bonjour, ça va?" --duration 10 --french
  human-type run --text "Fix this later" --duration 8 --defer 100 --dry-run

Notes:
  Defaults to OPENCLAW_CDP_URL when set, otherwise http://127.0.0.1:18800
`);
    return;
  }

  if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
    info(`human-type v${VERSION}`);
    return;
  }

  const subArgs = { ...args, _: args._.slice(1) };

  switch (cmd) {
    case 'run':      await cmdRun(subArgs);     break;
    case 'generate': await cmdGenerate(subArgs); break;
    case 'play':     await cmdPlay(subArgs);     break;
    case 'inspect':  cmdInspect(subArgs);        break;
    default:         die(`Unknown command: ${cmd}. Run human-type --help`);
  }
}

main().catch(err => {
  process.stderr.write(`\x1b[31mFatal: ${err.message}\x1b[0m\n`);
  process.exit(1);
});


path: package.json
content: {
  "name": "human-type-cli",
  "version": "2.0.1",
  "description": "Type text into a browser like a real human — adjacent-key typos, deferred corrections, French accent omissions, variable speed. OpenClaw skill.",
  "main": "./engine.js",
  "bin": {
    "human-type": "./human-type.js"
  },
  "scripts": {
    "test": "node ./engine.test.js"
  },
  "keywords": [
    "openclaw",
    "skill",
    "browser",
    "automation",
    "human-typing",
    "stealth",
    "playwright",
    "french",
    "deferred"
  ],
  "license": "MIT-0",
  "engines": {
    "node": ">=20"
  },
  "dependencies": {
    "playwright-core": "^1.40.0"
  },
  "files": [
    "engine.js",
    "engine.test.js",
    "human-type.js",
    "player.js",
    "SKILL.md",
    "README.md"
  ],
  "openclaw": {
    "skill": "human-type",
    "emoji": "⌨️"
  }
}


path: player.js
content: /**
 * human-type browser player v2
 * Handles all event types: type, delete, pause, arrow, select-back, select-replace.
 */

const DEFAULT_CDP_URL = 'http://127.0.0.1:18800';

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function hasPlaywright() {
  try {
    require.resolve('playwright-core');
    return true;
  } catch (_) {}
  return false;
}

function getFirstPage(browser) {
  for (const context of browser.contexts()) {
    const page = context.pages()[0];
    if (page) return page;
  }
  return null;
}

// ─── Playwright player ────────────────────────────────────────────────────────

async function playWithPlaywright(events, opts = {}) {
  const { chromium } = require('playwright-core');
  const { cdpUrl = DEFAULT_CDP_URL, selector, delayStart = 500 } = opts;

  let browser, page;

  try {
    browser = await chromium.connectOverCDP(cdpUrl);
  } catch (_) {
    throw new Error(
      `Could not connect to a Chromium browser at ${cdpUrl}.\n` +
      'Start the OpenClaw browser first with: openclaw browser start\n' +
      'Then open a page with: openclaw browser open <url>\n' +
      'Or pass --cdp-url to point at a different browser.'
    );
  }

  page = getFirstPage(browser);
  if (!page) throw new Error('Could not get a page handle from the browser.');

  if (selector) {
    await page.click(selector);
    await sleep(150);
  }

  await page.bringToFront();

  if (delayStart > 0) await sleep(delayStart);

  const startTime = Date.now();

  for (const ev of events) {
    const elapsed = Date.now() - startTime;
    const waitFor = ev.time - elapsed;
    if (waitFor > 0) await sleep(waitFor);

    switch (ev.type) {
      case 'type':
        await page.keyboard.type(ev.ch, { delay: 0 });
        break;

      case 'delete':
        await page.keyboard.press('Backspace');
        break;

      case 'pause':
        // Timing gap already handled above — no key action
        break;

      case 'arrow':
        // Press arrow key ev.n times
        for (let i = 0; i < ev.n; i++) {
          await page.keyboard.press(`Arrow${ev.dir}`);
        }
        break;

      case 'select-back':
        // Shift+Left × n  (selects n chars behind cursor)
        for (let i = 0; i < ev.n; i++) {
          await page.keyboard.press('Shift+ArrowLeft');
        }
        break;

      case 'select-replace':
        // Type the correct char — replaces the selection
        await page.keyboard.type(ev.ch, { delay: 0 });
        break;
    }
  }
}

// ─── Public ───────────────────────────────────────────────────────────────────

async function playScript(events, opts = {}) {
  if (!hasPlaywright()) {
    throw new Error(
      'Missing dependency: playwright-core. Reinstall the package or run `npm install`.'
    );
  }
  await playWithPlaywright(events, opts);
}

module.exports = { playScript, hasPlaywright, DEFAULT_CDP_URL };


path: README.md
content: # human-type

> Type text into a browser like a real human — typos, backspaces, pauses, variable speed.

An [OpenClaw](https://github.com/openclaw/openclaw) skill + standalone CLI that generates a realistic **keystroke script** from any target string and plays it back in a browser with human-like timing.

---

## Install

```bash
npm install -g human-type-cli
```

Or run directly without installing:

```bash
npx human-type-cli run --text "Hello world" --duration 5
```

---

## Quick start

```bash
# Type "Hello, world!" over 6 seconds with default settings
human-type run --text "Hello, world!" --duration 6

# Type into a specific field (CSS selector)
human-type run \
  --text "My name is Alex and I'd like more info." \
  --duration 12 \
  --mistakes 25 \
  --pauses 20 \
  --selector "#contact-message"

# Preview the script without actually typing
human-type run --text "Preview me" --duration 5 --dry-run
```

---

## How it works

`human-type` doesn't just add random delays between keystrokes. It models how humans actually type:

1. **Adjacent-key typos** — wrong chars are picked from real QWERTY neighbors (so `e` becomes `w`, `r`, `d`, or `s` — not a random char)
2. **Backspace correction** — mistakes are deleted character-by-character before retyping
3. **Thinking pauses** — random hesitation gaps mid-sentence
4. **Speed jitter** — each keystroke has independent timing noise (0.4×–1.8× of average)
5. **Seeded reproducibility** — fix `--seed` for the exact same sequence every run

The final text produced is always exactly what you specified.

---

## Commands

### `run` — Type directly into the browser

```
human-type run [options]

  --text <string>       Final text to produce (required)
  --duration <seconds>  Total typing time (default: 10)
  --mistakes <0-100>    Typo rate as % (default: 20)
  --pauses <0-100>      Pause frequency as % (default: 15)
  --selector <css>      CSS selector to focus before typing
  --delay-start <ms>    Wait before first keystroke (default: 500)
  --seed <number>       Fix RNG for reproducible output
  --dry-run             Print script, don't type
  --cdp-url <url>       Chrome DevTools Protocol URL (default: http://127.0.0.1:18800)
```

### `generate` — Save a script to JSON

```
human-type generate --text "..." --output ./script.json [same options as run]
```

### `play` — Replay a saved script

```
human-type play ./script.json [--selector <css>] [--cdp-url <url>]
```

### `inspect` — Show stats about a script

```
human-type inspect ./script.json
```

Output:
```
  Target text  "Hello, world!"
  Events       47
  Keystrokes   39
  Mistakes     8  (21%)
  Pauses       5
  Total time   6.2s
  Avg WPM      48
  Seed         839204712
```

---

## Script format

The JSON event format is simple and portable — use it to drive any automation tool:

```json
[
  { "type": "type",   "ch": "H", "time": 0,    "duration": 195 },
  { "type": "type",   "ch": "e", "time": 195,  "duration": 210 },
  { "type": "type",   "ch": "p", "time": 405,  "duration": 180 },
  { "type": "delete",            "time": 585,  "duration": 145 },
  { "type": "type",   "ch": "l", "time": 730,  "duration": 220 },
  { "type": "pause",             "time": 950,  "duration": 480 },
  ...
]
```

| Field | Values | Description |
|---|---|---|
| `type` | `"type"` / `"delete"` / `"pause"` | Event kind |
| `ch` | character string | Key to press (only on `type` events) |
| `time` | ms integer | Offset from start |
| `duration` | ms integer | Gap until next event |

---

## Typical OpenClaw workflow

```bash
# 1. Open the page
openclaw browser open https://example.com/signup

# 2. Find the element refs
openclaw browser snapshot --interactive

# 3. Type like a human
human-type run \
  --text "Alexandra Chen" \
  --duration 4 \
  --mistakes 15 \
  --selector "input[name='name']"

# 4. Type email naturally
human-type run \
  --text "[REDACTED_SECRET]" \
  --duration 6 \
  --mistakes 10 \
  --pauses 5 \
  --selector "input[type='email']"

# 5. Verify
openclaw browser snapshot --interactive
```

---

## Tips

| Goal | Setting |
|---|---|
| Natural adult typist | `--mistakes 15 --pauses 10 --duration` ~50 WPM |
| Fast typist, few errors | `--mistakes 5 --pauses 5` |
| Slow/careful | `--mistakes 30 --pauses 30` longer duration |
| Exact replay in tests | `--seed 12345` |
| Multi-field form | Run once per field with different `--selector` |

**WPM reference**: 40–70 WPM is average adult. At 50 WPM, 50 characters ≈ 10 seconds.

---

## Browser support

Requires a running Chromium-based browser accessible via CDP:

- **OpenClaw managed browser**: default is `http://127.0.0.1:18800`
- **Custom OpenClaw profile or remote browser**: pass `--cdp-url http://host:port`

The CLI uses `playwright-core` to attach over CDP. It does not launch its own browser and does not rely on OpenClaw's internal HTTP control routes.

---

## License

MIT


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)