| name | demo |
| description | Run a TerranSoul feature demo by DRIVING the live app via Playwright over CDP (WebView2 on 127.0.0.1:9222). Use this skill whenever the user types /demo or /demo <scenario> — e.g. /demo jd, /demo resume, /demo e2e, /demo show. Especially trigger for /demo jd, which automates TerranSoul end-to-end: attach 4 resume PDFs, quick-import, and ask in English/Vietnamese/Japanese which candidate fits a role. |
| argument-hint | [scenario] [level] [count] — e.g. jd | jd 3 | jd 3 40000 | (omit to list available demos) |
| user-invocable | true |
Available demos
| Scenario | What it shows |
|---|
jd | Drives TerranSoul live (Playwright/CDP): attach 4 resume PDFs → quick ~5s import → ask in EN/VI/JA which candidate fits a role, at a chosen thinking level |
jd [1‑3] | Same, but pins the thinking level: 1 = Chat (no thinking, fastest), 2 = Think (reason-and-rerank), 3 = Max (agentic verify → highest accuracy). Omit the number to run all three and compare. |
jd [1‑3] [count] | Scaled bench run (NOT the 4-PDF CDP path): generate count deterministic multilingual résumés (same seed/generator as the 1M bench — just fewer rows), ingest them into the REAL production store on the C: SSD, then answer the same EN/VI/JA JDs at the chosen level and score NDCG@10 per language. E.g. jd 3 40000 = Max over 40,000 résumés. |
jd [1‑3] [count] record / "record with voice" / "as an mp4" | Live CDP flow, scaled corpus, WITH AN AUDIBLE TWO-VOICE CONVERSATION, recorded to mp4 — see § "/demo jd — recorded with voice" below. Different code path from the plain scaled bench run above: this one DOES drive the live UI (like the bare jd [1-3] flow) but ALSO ingests a count-row corpus first (timed, shown live) before asking. Produces TWO mp4s: a full real-time video and a fast-forward timelapse with a burned-in real-time clock (dead time sped up, speech kept at 1x, nothing skipped). |
If $ARGUMENTS is empty or unrecognised, print the table above and ask which demo to run.
If it contains a scenario and a 1, 2, or 3, pass the number to the runner as the level (see below).
If it asks for a recording / video / voice (any of: "record", "mp4", "video", "with voice", "hear"), route to
the recorded-with-voice runner (§ "/demo jd — recorded with voice") — this OVERRIDES the plain scaled-bench
routing below even when a trailing count is present, since the scaled bench script has no UI/voice/video
capability at all (it's a headless Node script — see its own section for why).
Otherwise, if it contains a trailing count (a number ≥ 100, e.g. jd 3 40000) with NO recording/voice ask,
route to the scaled runner (§ "/demo jd — scaled by count") instead of the live CDP flow.
/demo jd
This demo drives the live TerranSoul app end-to-end via Playwright over CDP
(WebView2 on 127.0.0.1:9222 — the [dev-with-cdp] setup). It does NOT just
open HTML and read instructions; the runner attaches the resumes, waits for
ingestion, and asks TerranSoul real questions.
Step 0 — Verify the [dev-with-cdp] app + Ollama are up
CDP is exposed by the dev-with-cdp dev script (npm run dev). Check it and the
TerranSoul WebView page in one shot:
curl -s --max-time 3 http://127.0.0.1:9222/json/list
Also confirm Ollama has a chat + embed model (ingestion and the questions need
it):
curl -s --max-time 3 http://127.0.0.1:11434/api/tags
Expect nomic-embed-text + a gemma4 chat model. If Ollama is down, tell the
user to run ollama serve.
Report which instance + models you're using before continuing.
Step 1 — Print the candidate roster (context)
┌─────────────────────────────────────────────────────────────────────┐
│ 4 CANDIDATE RESUMES — about to be imported into TerranSoul │
├────┬────────────────────┬──────────────────────────────┬────────────┤
│ # │ Name │ Role │ Lang │
├────┼────────────────────┼──────────────────────────────┼────────────┤
│ 1 │ Alex Nguyen │ Senior Frontend Engineer │ EN 🔵 │
│ 2 │ Jordan Smith │ Backend & Platform Engineer │ EN 🟢 │
│ 3 │ Trần Minh Anh │ Kỹ sư Full-stack │ VI 🟣 │
│ 4 │ 田中 陽菜 (Haruna) │ 3D グラフィックスエンジニア │ JA 🌸 │
└────┴────────────────────┴──────────────────────────────┴────────────┘
Step 2 — Run the automated flow against TerranSoul
This is the core. Run the runner — it connects over CDP and drives the app:
node Real-E2E/jd/run-demo.mjs
node Real-E2E/jd/run-demo.mjs 3
node Real-E2E/jd/run-demo.mjs --level 1
If the user passed a level number (/demo jd 3), forward it as the positional
argument. With no level, the runner asks each JD at ALL three levels so the
accuracy-for-speed trade is shown side by side (Chat fast/looser → Max slow/100 %).
What it does, fully automated:
connect → chromium.connectOverCDP(127.0.0.1:9222) → TerranSoul WebView
prepare → app-ready, dismiss onboarding/dialogs, ensure local-Ollama brain
ingest → chat-first intake (CHAT-FIRST-INTAKE-1): types `/ingest "<pdf>"`
into the chat input per resume — the Knowledge Quest dialog was
removed; the interceptor dispatches chat_ingest_path and posts
the outcome summary ("📄 Reading …") as the assistant reply.
Documents route through the DURABLE ingest pipeline; whole-file
source_hash dedup keeps repeat runs from accumulating duplicate
resume rows.
import → if the resumes are ALREADY in the brain (FTS-probed via
search_memories), shows a fast ~10s scripted "import" animation
and SKIPS the slow real ingest — answers are grounded from the
seeded rows. Only a genuinely empty brain pays the one-time real
ingest+embed.
level → for each selected thinking level, selects the real chat mode in the
UI first (settingsStore.saveSettings → chat_mode/reasoning_effort/
harness_mode, exactly what the ChatView mode-picker writes):
1=Chat 💬 (off/off), 2=Think 🧠 (high/off), 3=Max 🎯 (high/digest).
ask → EN: best fit for a Vue 3 + Rust full-stack role?
VI: same question in Vietnamese
JA: best fit for a Three.js + VRM 3D frontend role?
(asked once per selected level, with per-answer response latency)
print → TerranSoul's grounded answers per level, + saves
output/demo-<ts>/demo-result.json ({ levels: [{ level, mode, answers }] })
Flags: --connect-only (connectivity smoke test); 1|2|3 or --level N (pin one thinking level).
Stream/relay the runner's stdout to the user as it narrates each step. When it
finishes, surface the three answers and the transcript path.
Step 3 — Summarize
After the runner completes, report:
- memories added (resumes ingested),
- the EN / VI / JA answers TerranSoul gave (which candidate it picked + why),
- the saved transcript:
Real-E2E/jd/output/demo-<ts>/demo-result.json.
If a step failed, surface the runner's ❌ line verbatim and diagnose (CDP down,
Ollama model missing, chat-intake selectors/reply format changed).
/demo jd — scaled by count (/demo jd [1-3] [count])
When $ARGUMENTS carries a trailing count (e.g. jd 3 40000), this is NOT
the 4-PDF live CDP flow. It is the scaled bench run: it generates count
deterministic multilingual résumés (the SAME seed + generator as the
1,000,000-résumé bench — résumé N is a pure function of (seed, N), so it is
just the first count rows of the same corpus), ingests them into the REAL
production MemoryStore on the C: SSD through the longmemeval-ipc shim, then
answers the same three EN/VI/JA JDs at the chosen thinking level and scores
NDCG@10 per language against the mechanical gold predicate (never shown to the
system). Single-source-of-truth CRUD; AGI-pure; no gold leakage.
Run the scaled runner (pins the prebuilt shim → no cargo, coexists with other
benches; uses a count-scoped store C:/TerranSoul/jd-<count>/ so it never
touches the 1M store):
node benchmark/scripts/jd-demo-run.mjs --count 40000 --level 3
node benchmark/scripts/jd-demo-run.mjs --count 40000 --level all
npm run bench:jd:demo -- --count 40000 --level all
--level maps 1|2|3 → Chat|Think|Max (or all). --reuse-store keeps an
existing count-scoped store and ingests only the tail. It writes
benchmark/results/jd-<count>/jd-demo-<count>.json (per-level per-language
NDCG@10, response time per JD, and the one-time ingest time) plus the per-stage
evidence (report.json, chat-pipeline-*, jd-max-*).
Report the per-language NDCG@10 at each level, the response time per JD, and the
count-row ingest time.
/demo jd — recorded with voice (/demo jd 3 [count] record / "with voice" / "as an mp4")
When the request asks to record, produce a video/mp4, or hear the demo, this is
NEITHER the plain 4-PDF live-CDP flow (§ "/demo jd", no corpus, no recording, no voice) NOR the
headless scaled bench (§ "/demo jd — scaled by count", no UI, no voice, no video — it's a Node
script that never opens a browser). It drives the live app like the plain flow, but ALSO ingests
a count-row scaled corpus first (timed, shown live), and produces an actual mp4 with sound.
Runner: node Real-E2E/jd/run-demo-4000-voice.mjs --count <N> (despite the filename, --count is a
real flag — default 4000, but any size works as long as the corpus is already generated at
Real-E2E/jd/output/corpus-<N>/resumes.jsonl via jd-corpus.mjs generate --count <N> --seed 20260703 --out Real-E2E/jd/output/corpus-<N>, matching the § "scaled by count" corpus command).
⚠️ CDP screencast video has NO AUDIO TRACK, ever — this is a hard Chromium/CDP limitation, not
a TerranSoul limitation. Page.startScreencast only emits video frames
(Page.screencastFrame); there is no accompanying audio capture API. A recording that just wraps
startCdpScreencast around a live chat turn — even with TerranSoul's own TTS audibly playing
through the speakers while it records — produces a silent mp4. Verify this yourself before
shipping any recording: ffprobe -show_entries stream=codec_type <file>.mp4 must show an audio
stream, not just video. (An earlier pass on this demo shipped a silent video because this was
missed — do not repeat that.)
Fix: don't rely on live audio capture. Synthesize the audio as standalone clips with known
timestamps, then mux them onto the video afterward. The pattern (already implemented in
run-demo-4000-voice.mjs — read it before reinventing this):
- Before sending each question, set the Supertonic voice to a presenter style (
M3 — or any
style DIFFERENT from whichever style TerranSoul answers with) via the set_tts_voice Tauri
command, then synthesize the question text standalone via the synthesize_tts Tauri command
({ text, langHint, contextText: null, emotionHint: null } → returns raw WAV bytes as a plain
array; Buffer.from(bytes) in Node). This call never goes through the live chat/streaming
pipeline — it's a pure text→audio function, safe to call outside any chat turn.
- Restore the voice to TerranSoul's own style (
F1) with another set_tts_voice call BEFORE
sending the actual chat message — live in-app playback (and the on-screen "talking" character
animation) reads whatever voice is currently set, so this must happen before the LLM turn, not
after.
- Send the question normally; once the reply text lands, synthesize a clean standalone copy of
that SAME reply text (again via
synthesize_tts, with TerranSoul's voice + the same
langHint) for the audio track — don't try to capture the live playback itself.
- Record each clip's video-relative timestamp (
Date.now() - recordingStartedAt, where
recordingStartedAt is captured right when the screencast starts, not script start).
- After
stopScreencast() produces the silent out.mp4, mux the audio in with ffmpeg: one
adelay=delays=<offsetMs>:all=1 filter per clip (shifts each clip to its recorded timestamp),
then amix=inputs=N:normalize=0:duration=longest to sum the now non-overlapping clips into one
conversation.wav, then a final ffmpeg -i out.mp4 -i conversation.wav -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac <final>.mp4 to mux it onto the video. -shortest is deliberately NOT used —
the video should keep playing (silently) past the last spoken line.
Why two distinct voice styles, and why "not through TerranSoul": a demo where TerranSoul
speaks every line (the question typed silently, then TerranSoul answers) does not sound like a
conversation — it sounds like one voice narrating both sides. The project owner's explicit
correction: the QUESTION must be read aloud in a voice that is audibly NOT TerranSoul's own reply
voice, and that question audio must be generated standalone (never by making TerranSoul itself
speak the question through its own chat/persona pipeline — that would still be "one voice talking
to itself," just re-ordered). Supertonic ships 10 installed voice styles
(%APPDATA%/com.terransoul/voice/supertonic/v2/voice_styles/{F1..F5,M1..M5}.json) — pick any two
that differ; run-demo-4000-voice.mjs uses M3 (presenter) / F1 (TerranSoul).
Multilingual note: pass the question's own language as langHint (en/vi/ja — ISO
639-1) to BOTH the presenter clip and the assistant-reply clip. ✅ Vietnamese mispronunciation
FIXED 2026-07-07 (was a real model-version bug, not an M3-specific quality issue): the project
owner reported the Vietnamese presenter clip was mispronounced. Root-caused by loading the
installed unicode_indexer.json directly and testing real Vietnamese text against it: TerranSoul
was pinned to Supertone/supertonic-2 in supertonic_manifest.rs, whose training vocabulary has
NO token at all — in any Unicode form — for two Vietnamese combining diacritics (COMBINING HORN
U+031B: ư/ơ; COMBINING DOT BELOW U+0323: the "nặng" tone), silently dropping them for every voice
preset equally (the text encoder/indexer is shared across all 10 presets — confirmed this was
never an M3-only problem). voice::supertonic_tts::AVAILABLE_LANGS had already been written for
v3's full 31-language spec; only the manifest never got bumped to match. Fixed by repointing
MODEL_REPO/MODEL_REVISION/INSTALL_SUBDIR to Supertone/supertonic-3 (exact byte sizes +
commit SHA pulled via the HF API) — re-verified against the newly-downloaded v3 indexer: the same
Vietnamese sentence that dropped 9 characters under v2 (NFKD form) drops 0 under v3 (only 2
unrelated em-dashes remain, cosmetic punctuation). If this regresses again, re-run the same
direct-indexer-load reproduction before assuming it's voice-specific — don't guess by ear alone.
On-screen visibility (fixed 2026-07-07 — read before assuming the UI shows anything by
default): TerranSoul's chat message list / task-progress panel is COLLAPSED by default
(useChatExpansion's chatDrawerExpanded starts false, src/composables/useChatExpansion.ts).
A recording taken without expanding it shows nothing during /ingest — no "📄 Reading …"
task cards, no progress — just the idle character on a starfield (confirmed by extracting video
frames). handleIngestCommand (src/views/ChatView.vue) also never calls showSubtitle(), so
even the collapsed-mode karaoke-subtitle overlay doesn't pick up ingest replies either. Always
call ensureChatDrawerExpanded(page) once, right after ensureVoiceEnabled, before starting the
CDP screencast — it clicks .chat-drawer-toggle and waits for .chat-history to become
visible. This one fix also happens to eliminate a second bug: the collapsed-mode karaoke subtitle
(v-if="karaokeDialogEnabled && subtitleVisible && !chatDrawerExpanded") auto-scrolls to keep the
"currently speaking" sentence in view, which crops the box to a mid-sentence line instead of the
reply's actual first line for long replies — expanding the drawer bypasses that overlay entirely
in favor of normal scrollable chat bubbles.
Presenter voice must be visibly synced to the on-screen question, not typed/sent instantly
(fixed 2026-07-07): capture the presenter clip's own offset/duration AFTER synthesis completes
(not before — synthesis itself takes several real seconds, and capturing the offset first drifts
the on-screen action ahead of where the muxed audio clip actually starts), then insert the
question via page.locator('.chat-input').pressSequentially(text, { delay }) with delay picked
so the total typing time roughly matches the presenter clip's spoken duration — not an instant
.fill(). Otherwise the video shows the question already sent (and TerranSoul already
"thinking") while the presenter audio is still only a few words in.
Ingest completion must be tracked by task ID, not by matching a description regex (fixed
2026-07-07 — this was a real bug, not just a style nit): ingestViaChat only waits for the
"Reading …" acknowledgment reply, not the background task's real completion. A task's
description changes to "Done! N chunks…" or "📚 Already studied…" the INSTANT it reaches a
terminal status (src-tauri/src/commands/ingest.rs:444-518) — so a completion check that
filters tasks by /reading /i in their description, then checks THOSE tasks' status, can never
be true (the two conditions are mutually exclusive by construction). Track the specific task IDs
created by the ingest pass instead (kind === 'ingest', captured before/after sending the
/ingest commands) and poll those IDs by identity for a terminal status
(completed/failed/cancelled) — status changes, unlike description text, are safe to match
regardless of what the description says at the time.
Report the per-question audio-clip timestamps, the corpus ingest time, and the final mp4 path
(Real-E2E/jd/output/demo-<count>-voice-<timestamp>/demo-<count>-voice.mp4). If the audio mux step
fails, say so explicitly and report the silent video's path instead of claiming success.
CDP screencast timing gotcha — nominal vs. real frame duration. Page.screencastFrame is
duty-cycled: Chromium emits a frame only when the page visually changes, at irregular real-world
intervals (bursts during streaming/animation, near-silence while idle). Giving every captured
frame the SAME fixed nominal duration in the ffmpeg concat file (e.g. "1/8s each", the pattern in
Real-E2E/_shared/helpers.ts) makes the output's total duration frameCount / framerate, which
has no relationship to the real wall-clock span once the actual capture rate differs from the
chosen encoding rate (measured: 12,464 frames over a real ~530s recording encoded at a nominal
8fps would produce a video ~3x too long). Since the audio clips are muxed on at REAL millisecond
offsets, a stretched video timeline puts them audibly out of sync with what's on screen as the
video progresses. Fix (already in startCdpScreencast in run-demo-4000-voice.mjs): track each
frame's real arrival timestamp and use the ACTUAL measured gap to the next frame as its ffmpeg
concat duration — the video's total length then equals real elapsed time, matching the audio
offsets exactly (verified: 529.48s video vs. 526.03s audio, within 0.7%). This fix has NOT been
ported to the shared _shared/helpers.ts screencast helper — any other Real-E2E spec using that
helper still has the stretched-timeline bug.
CDP screencast gotcha #2 — a rare individually-corrupt frame desyncs the ENTIRE raw-JPEG concat,
not just itself (found 2026-07-07, fixed in startCdpScreencast). Even with per-frame durations
correct, one run (10,000-résumé demo, 20,913 frames) produced a video 2.8x longer than its real
~846s span despite the concat file's declared durations summing to exactly 846.24s. Root cause,
found by bisecting the frame list down to the exact boundary that broke: Page.screencastFrame
occasionally emits a frame that is individually corrupted — valid JPEG SOI/EOI bytes (so a
byte-level structural check reports it as fine) but fails to actually decode
(ffmpeg -i frame.jpg -f null - errors with "No JPEG data found", "Invalid frame dimensions 0x0",
sometimes even misdetecting the codec as mpeg1video). Exactly 3 such frames existed out of
20,913 (≈0.014%) — rare, but -f concat -safe 0 treats the whole sequence as ONE continuing raw
MJPEG elementary stream, so a single bad frame desyncs its parser for everything after it,
producing a multi-thousand-percent duration blowup (confirmed: encoding just the one bad pair
alone produced a 3360s output from 0.06s of declared duration). Fix: encode in small batches
(500 frames) via encodeBatchWithRepair — each batch is checked against its own declared
duration (ffprobe actual vs. sum of duration lines; a >3x or <0.1x mismatch flags it), and
only a FLAGGED batch pays the cost of decode-checking each of its own frames individually (cheap —
at most 500 ffmpeg -f null - calls, not the whole recording) to find and drop the culprit,
folding its duration into the neighboring frame so total timing is unaffected. All batches are
then concatenated via a SEPARATE final -f concat pass over the batches' own .mp4 outputs
(-c copy, stream-copy) — concatenating proper H.264/MP4 containers this way is robust; it's only
concatenating RAW JPEG BYTES that has this fragility. Verified on the actual broken 10k run's
frames: 42 batches, 2 flagged, 3 frames dropped total, final duration 848.04s (0.2% off the real
846.24s) — vs. the original single-shot approach's 2398.96s. This is a general CDP-screencast
robustness fix, not specific to this demo — any Real-E2E spec capturing enough frames has some
(small but nonzero) chance of hitting the same corrupt-frame class of failure.
The fast-forward timelapse variant (real-time clock overlay, dead time sped up)
A full-length real-time recording of a multi-minute ingest + multi-second LLM "thinking" gap is
accurate but tedious to watch end to end. run-demo-4000-voice.mjs also builds a SECOND output —
demo-<count>-voice-timelapse.mp4 — from the same muxed real-time video, via
buildTimelapseWithTimer. Nothing is skipped or cut; only PLAYBACK SPEED varies:
- Burn a real-elapsed-time clock onto the video first, while it is still 1:1 real-time-synced
(trivial at this point —
drawtext's %{pts\:hms} expression IS the real elapsed time). Doing
this before any speed change means the displayed digits are always correct without having to
recompute a piecewise real-time-to-output-time mapping afterward.
- Track "keep at normal speed" windows as the script runs — every spoken-audio segment
(presenter reading the question, TerranSoul's synthesized reply) gets an explicit
{ startMs, endMs, speed: 1 } entry in speedSegments, using the exact same offsets/durations
already computed for the audio mux (clip offset + wavDurationSeconds(wav)).
- Fill every gap with an implicit fast-forward segment (
normalizeSpeedSegments, default
FAST_FORWARD_SPEED = 8) — covers [0, totalRecordedMs] exactly once, so ingest progress bars
and LLM-thinking gaps fly by while every line of speech stays intelligible at 1x.
- Cut + speed-remap + concat in ONE ffmpeg
filter_complex (timelapseFilterComplex): each
segment becomes trim+setpts=PTS/speed (video) and atrim+asetrate=<rate>*speed,aresample
(audio — pitch-shifting is fine here since fast segments have no meaningful speech), then all
segments concat back into one continuous stream. No intermediate per-segment files.
Windows drawtext fontfile gotcha: ffmpeg's filtergraph parser treats : as the option
separator, so a Windows absolute path (C:/Windows/Fonts/arial.ttf) collides with its own drive
letter no matter how it's quoted/escaped (verified empirically — every escaping variant tried
failed to parse). Fix: run ffmpeg with cwd set to the font's directory and pass a bare relative
filename (fontfile=arial.ttf), which has no colon to collide with at all.
Report BOTH video paths — the full real-time video (with audio) and the timelapse (with the
real-time clock burned in) — and mention the FAST_FORWARD_SPEED used.
Optional — presentation controls (manual)
For a live audience you can also drive the intro scene by hand from a separate
controller window (scene on the projector, controls on your laptop):
Invoke-Item (Resolve-Path "Real-E2E\jd\scene-control.html")
Click Start to pop up the scene, then Start / Pause / Restart. The scene
also works standalone (scene-4-resumes-scroll.html, auto-plays, same buttons).
Regenerate any asset with:
node Real-E2E/jd/build-resumes.mjs
Also available — the assertion-based regression spec
The same flow exists as a Playwright test (pass/fail + MCP self-learn):
npx playwright test --config Real-E2E/playwright.config.ts \
Real-E2E/jd/jd-resume-import.spec.ts --reporter=line
Use run-demo.mjs for a narrated live demo; use the spec for CI-style verification.