mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Compare commits
36 Commits
v0.6.0
...
t3code/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ba3911a2d
|
|||
|
e940205479
|
|||
|
d069df2124
|
|||
|
87bf3cef0c
|
|||
|
5c31be99b5
|
|||
|
05fe9c8fdf
|
|||
|
f89aec31e8
|
|||
|
6cf0272e7e
|
|||
|
7ea303361d
|
|||
|
95ec3946d2
|
|||
|
d71e9e841e
|
|||
|
047b349d05
|
|||
|
35946624c2
|
|||
|
bb13e3c895
|
|||
|
36181d8dfc
|
|||
|
40117d3b73
|
|||
|
7fcd3e8e94
|
|||
|
467ed02c80
|
|||
|
e68defbedf
|
|||
|
c4bea1f9ca
|
|||
|
7d76c44f7f
|
|||
|
5e944e8a17
|
|||
|
06745ff63a
|
|||
|
fcfa323e0f
|
|||
|
5506a75ef8
|
|||
|
e374e53d97
|
|||
|
6d8650994f
|
|||
|
a7c294a90c
|
|||
|
f005f542a3
|
|||
|
ee95e86ad5
|
|||
| 9eed37420e | |||
| 99f4d2baaf | |||
|
f4e8c3feec
|
|||
|
d0b308f340
|
|||
| 1b56360a24 | |||
|
68833c76c4
|
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
127
.agents/skills/subminer-change-verification/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: "subminer-change-verification"
|
||||
description: "Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation."
|
||||
---
|
||||
|
||||
# SubMiner Change Verification
|
||||
|
||||
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/classify_subminer_diff.sh`
|
||||
- Emits suggested lanes and flags from explicit paths or current git changes.
|
||||
- `scripts/verify_subminer_change.sh`
|
||||
- Runs selected lanes, captures artifacts, and writes a compact summary.
|
||||
|
||||
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
|
||||
|
||||
```bash
|
||||
export SUBMINER_VERIFY_SKILL="<path-to-skill>"
|
||||
```
|
||||
|
||||
## Default workflow
|
||||
|
||||
1. Inspect the changed files or user-requested area.
|
||||
2. Run the classifier unless you already know the right lane.
|
||||
3. Run the verifier with the cheapest sufficient lane set.
|
||||
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
|
||||
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
|
||||
6. Return:
|
||||
- verification summary
|
||||
- exact commands run
|
||||
- artifact paths
|
||||
- skipped lanes and blockers
|
||||
|
||||
## Quick start
|
||||
|
||||
Repo-source quick start:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||
```
|
||||
|
||||
Installed-skill quick start:
|
||||
|
||||
```bash
|
||||
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
|
||||
```
|
||||
|
||||
Classify explicit files:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
||||
launcher/main.ts \
|
||||
plugin/subminer/lifecycle.lua \
|
||||
src/main/runtime/mpv-client-runtime-service.ts
|
||||
```
|
||||
|
||||
Run automatic lane selection:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||
```
|
||||
|
||||
Installed-skill form:
|
||||
|
||||
```bash
|
||||
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
|
||||
```
|
||||
|
||||
Run targeted lanes:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||
--lane launcher-plugin \
|
||||
--lane runtime-compat
|
||||
```
|
||||
|
||||
Dry-run to inspect planned commands and artifact layout:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||
--dry-run \
|
||||
launcher/main.ts \
|
||||
src/main.ts
|
||||
```
|
||||
|
||||
## Lane guidance
|
||||
|
||||
- `docs`
|
||||
- For `docs-site/`, `docs/`, and doc-only edits.
|
||||
- `config`
|
||||
- For `src/config/` and config-template-sensitive edits.
|
||||
- `core`
|
||||
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
||||
- `launcher-plugin`
|
||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||
- `runtime-compat`
|
||||
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
|
||||
- `real-runtime`
|
||||
- Only after deliberate escalation.
|
||||
|
||||
## Real Runtime Escalation
|
||||
|
||||
Escalate only when the change claim depends on actual runtime behavior, for example:
|
||||
|
||||
- overlay appears, hides, or tracks a real mpv window
|
||||
- mpv launch flags or pause-until-ready behavior
|
||||
- plugin/socket/auto-start handshake under a real player
|
||||
- macOS/window-tracker/focus-sensitive behavior
|
||||
|
||||
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
|
||||
|
||||
## Artifact contract
|
||||
|
||||
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
|
||||
|
||||
- `summary.json`
|
||||
- `summary.txt`
|
||||
- `classification.txt`
|
||||
- `env.txt`
|
||||
- `lanes.txt`
|
||||
- `steps.tsv`
|
||||
- `steps/*.stdout.log`
|
||||
- `steps/*.stderr.log`
|
||||
|
||||
On failure, quote the exact failing command and point at the artifact directory.
|
||||
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
163
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: classify_subminer_diff.sh [path ...]
|
||||
|
||||
Emit suggested verification lanes for explicit paths or current local git changes.
|
||||
|
||||
Output format:
|
||||
lane:<name>
|
||||
flag:<name>
|
||||
reason:<text>
|
||||
EOF
|
||||
}
|
||||
|
||||
has_item() {
|
||||
local needle=$1
|
||||
shift || true
|
||||
local item
|
||||
for item in "$@"; do
|
||||
if [[ "$item" == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
add_lane() {
|
||||
local lane=$1
|
||||
if ! has_item "$lane" "${LANES[@]:-}"; then
|
||||
LANES+=("$lane")
|
||||
fi
|
||||
}
|
||||
|
||||
add_flag() {
|
||||
local flag=$1
|
||||
if ! has_item "$flag" "${FLAGS[@]:-}"; then
|
||||
FLAGS+=("$flag")
|
||||
fi
|
||||
}
|
||||
|
||||
add_reason() {
|
||||
REASONS+=("$1")
|
||||
}
|
||||
|
||||
collect_git_paths() {
|
||||
local top_level
|
||||
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
|
||||
return 0
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$top_level"
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
git diff --name-only --relative HEAD --
|
||||
git diff --name-only --relative --cached --
|
||||
else
|
||||
git diff --name-only --relative --
|
||||
git diff --name-only --relative --cached --
|
||||
fi
|
||||
git ls-files --others --exclude-standard
|
||||
) | awk 'NF' | sort -u
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -a PATHS=()
|
||||
declare -a LANES=()
|
||||
declare -a FLAGS=()
|
||||
declare -a REASONS=()
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
while [[ $# -gt 0 ]]; do
|
||||
PATHS+=("$1")
|
||||
shift
|
||||
done
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && PATHS+=("$line")
|
||||
done < <(collect_git_paths)
|
||||
fi
|
||||
|
||||
if [[ ${#PATHS[@]} -eq 0 ]]; then
|
||||
add_lane "core"
|
||||
add_reason "no changed paths detected -> default to core"
|
||||
fi
|
||||
|
||||
for path in "${PATHS[@]}"; do
|
||||
specialized=0
|
||||
|
||||
case "$path" in
|
||||
docs-site/*|docs/*|changes/*|README.md)
|
||||
add_lane "docs"
|
||||
add_reason "$path -> docs"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
|
||||
add_lane "config"
|
||||
add_reason "$path -> config"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
||||
add_lane "launcher-plugin"
|
||||
add_reason "$path -> launcher-plugin"
|
||||
add_flag "real-runtime-candidate"
|
||||
add_reason "$path -> real-runtime-candidate"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
|
||||
add_lane "runtime-compat"
|
||||
add_reason "$path -> runtime-compat"
|
||||
add_flag "real-runtime-candidate"
|
||||
add_reason "$path -> real-runtime-candidate"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$specialized" == "0" ]]; then
|
||||
case "$path" in
|
||||
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
|
||||
add_lane "core"
|
||||
add_reason "$path -> core"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$path" in
|
||||
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
|
||||
add_flag "broad-impact"
|
||||
add_reason "$path -> broad-impact"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#LANES[@]} -eq 0 ]]; then
|
||||
add_lane "core"
|
||||
add_reason "no lane-specific matches -> default to core"
|
||||
fi
|
||||
|
||||
for lane in "${LANES[@]}"; do
|
||||
printf 'lane:%s\n' "$lane"
|
||||
done
|
||||
|
||||
for flag in "${FLAGS[@]}"; do
|
||||
printf 'flag:%s\n' "$flag"
|
||||
done
|
||||
|
||||
for reason in "${REASONS[@]}"; do
|
||||
printf 'reason:%s\n' "$reason"
|
||||
done
|
||||
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
566
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
Executable file
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: verify_subminer_change.sh [options] [path ...]
|
||||
|
||||
Options:
|
||||
--lane <name> Force a verification lane. Repeatable.
|
||||
--artifact-dir <dir> Use an explicit artifact directory.
|
||||
--allow-real-runtime Allow explicit real-runtime execution.
|
||||
--allow-real-gui Deprecated alias for --allow-real-runtime.
|
||||
--dry-run Record planned steps without executing commands.
|
||||
--help Show this help text.
|
||||
|
||||
If no lanes are supplied, the script classifies the provided paths. If no paths are
|
||||
provided, it classifies the current local git changes.
|
||||
|
||||
Authoritative real-runtime verification should be requested with explicit path
|
||||
arguments instead of relying on inferred local git changes.
|
||||
EOF
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
date +%Y%m%d-%H%M%S
|
||||
}
|
||||
|
||||
timestamp_iso() {
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
}
|
||||
|
||||
generate_session_id() {
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
|
||||
basename "$tmp_dir"
|
||||
rmdir "$tmp_dir"
|
||||
}
|
||||
|
||||
has_item() {
|
||||
local needle=$1
|
||||
shift || true
|
||||
local item
|
||||
for item in "$@"; do
|
||||
if [[ "$item" == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
normalize_lane_name() {
|
||||
case "$1" in
|
||||
real-gui)
|
||||
printf '%s' "real-runtime"
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
add_lane() {
|
||||
local lane
|
||||
lane=$(normalize_lane_name "$1")
|
||||
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
|
||||
SELECTED_LANES+=("$lane")
|
||||
fi
|
||||
}
|
||||
|
||||
add_blocker() {
|
||||
BLOCKERS+=("$1")
|
||||
BLOCKED=1
|
||||
}
|
||||
|
||||
append_step_record() {
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
|
||||
}
|
||||
|
||||
record_env() {
|
||||
{
|
||||
printf 'repo_root=%s\n' "$REPO_ROOT"
|
||||
printf 'session_id=%s\n' "$SESSION_ID"
|
||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
|
||||
printf 'dry_run=%s\n' "$DRY_RUN"
|
||||
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
|
||||
printf 'session_home=%s\n' "$SESSION_HOME"
|
||||
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
|
||||
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
|
||||
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
|
||||
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
|
||||
printf 'pwd=%s\n' "$(pwd)"
|
||||
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
|
||||
git status --short 2>/dev/null || true
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
printf 'requested_paths=\n'
|
||||
printf ' %s\n' "${PATH_ARGS[@]}"
|
||||
fi
|
||||
} >"$ARTIFACT_DIR/env.txt"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local lane=$1
|
||||
local name=$2
|
||||
local command=$3
|
||||
local note=${4:-}
|
||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||
local stdout_rel="steps/${slug}.stdout.log"
|
||||
local stderr_rel="steps/${slug}.stderr.log"
|
||||
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
||||
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
||||
local status exit_code
|
||||
|
||||
COMMANDS_RUN+=("$command")
|
||||
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
||||
: >"$stderr_path"
|
||||
status="dry-run"
|
||||
exit_code=0
|
||||
else
|
||||
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
||||
status="passed"
|
||||
exit_code=0
|
||||
EXECUTED_REAL_STEPS=1
|
||||
else
|
||||
exit_code=$?
|
||||
status="failed"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
|
||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||
|
||||
if [[ "$status" == "failed" ]]; then
|
||||
FAILURE_STEP="$name"
|
||||
FAILURE_COMMAND="$command"
|
||||
FAILURE_STDOUT="$stdout_rel"
|
||||
FAILURE_STDERR="$stderr_rel"
|
||||
return "$exit_code"
|
||||
fi
|
||||
}
|
||||
|
||||
record_nonpassing_step() {
|
||||
local lane=$1
|
||||
local name=$2
|
||||
local status=$3
|
||||
local note=$4
|
||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||
local stdout_rel="steps/${slug}.stdout.log"
|
||||
local stderr_rel="steps/${slug}.stderr.log"
|
||||
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
||||
: >"$ARTIFACT_DIR/$stderr_rel"
|
||||
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||
}
|
||||
|
||||
record_skipped_step() {
|
||||
record_nonpassing_step "$1" "$2" "skipped" "$3"
|
||||
}
|
||||
|
||||
record_blocked_step() {
|
||||
add_blocker "$3"
|
||||
record_nonpassing_step "$1" "$2" "blocked" "$3"
|
||||
}
|
||||
|
||||
record_failed_step() {
|
||||
FAILED=1
|
||||
FAILURE_STEP=$2
|
||||
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
||||
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
|
||||
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
|
||||
add_blocker "$3"
|
||||
record_nonpassing_step "$1" "$2" "failed" "$3"
|
||||
}
|
||||
|
||||
find_real_runtime_helper() {
|
||||
local candidate
|
||||
for candidate in \
|
||||
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
|
||||
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
acquire_real_runtime_lease() {
|
||||
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
|
||||
local lease_dir="$lease_root/exclusive-real-runtime"
|
||||
mkdir -p "$lease_root"
|
||||
if mkdir "$lease_dir" 2>/dev/null; then
|
||||
REAL_RUNTIME_LEASE_DIR="$lease_dir"
|
||||
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local owner=""
|
||||
if [[ -f "$lease_dir/session_id" ]]; then
|
||||
owner=$(cat "$lease_dir/session_id")
|
||||
fi
|
||||
add_blocker "real-runtime lease already held${owner:+ by $owner}"
|
||||
return 1
|
||||
}
|
||||
|
||||
release_real_runtime_lease() {
|
||||
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
|
||||
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
|
||||
local owner
|
||||
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
|
||||
if [[ "$owner" != "$SESSION_ID" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$REAL_RUNTIME_LEASE_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
compute_final_status() {
|
||||
if [[ "$FAILED" == "1" ]]; then
|
||||
FINAL_STATUS="failed"
|
||||
elif [[ "$BLOCKED" == "1" ]]; then
|
||||
FINAL_STATUS="blocked"
|
||||
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
|
||||
FINAL_STATUS="passed"
|
||||
else
|
||||
FINAL_STATUS="skipped"
|
||||
fi
|
||||
}
|
||||
|
||||
write_summary_files() {
|
||||
local lane_lines
|
||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||
|
||||
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
||||
SESSION_ID_ENV="$SESSION_ID" \
|
||||
FINAL_STATUS_ENV="$FINAL_STATUS" \
|
||||
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
|
||||
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
|
||||
SESSION_HOME_ENV="$SESSION_HOME" \
|
||||
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
|
||||
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
|
||||
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
|
||||
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
|
||||
STARTED_AT_ENV="$STARTED_AT" \
|
||||
FINISHED_AT_ENV="$FINISHED_AT" \
|
||||
FAILED_ENV="$FAILED" \
|
||||
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
|
||||
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
|
||||
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
|
||||
bun -e '
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function readLines(filePath) {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
}
|
||||
|
||||
const artifactDir = process.env.ARTIFACT_DIR_ENV;
|
||||
const reportsDir = path.join(artifactDir, "reports");
|
||||
const lanes = readLines(path.join(artifactDir, "lanes.txt"));
|
||||
const blockers = readLines(path.join(artifactDir, "blockers.txt"));
|
||||
const requestedPaths = readLines(path.join(artifactDir, "requested-paths.txt"));
|
||||
const steps = readLines(path.join(artifactDir, "steps.tsv")).map((line) => {
|
||||
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
|
||||
return {
|
||||
lane,
|
||||
name,
|
||||
status,
|
||||
exitCode: Number(exitCode || 0),
|
||||
command,
|
||||
stdout,
|
||||
stderr,
|
||||
note,
|
||||
};
|
||||
});
|
||||
const summary = {
|
||||
sessionId: process.env.SESSION_ID_ENV || "",
|
||||
artifactDir,
|
||||
reportsDir,
|
||||
status: process.env.FINAL_STATUS_ENV || "failed",
|
||||
selectedLanes: lanes,
|
||||
failed: process.env.FAILED_ENV === "1",
|
||||
failure:
|
||||
process.env.FAILED_ENV === "1"
|
||||
? {
|
||||
command: process.env.FAILURE_COMMAND_ENV || "",
|
||||
stdout: process.env.FAILURE_STDOUT_ENV || "",
|
||||
stderr: process.env.FAILURE_STDERR_ENV || "",
|
||||
}
|
||||
: null,
|
||||
blockers,
|
||||
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV || "git-inferred",
|
||||
requestedPaths,
|
||||
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
|
||||
startedAt: process.env.STARTED_AT_ENV || "",
|
||||
finishedAt: process.env.FINISHED_AT_ENV || "",
|
||||
env: {
|
||||
home: process.env.SESSION_HOME_ENV || "",
|
||||
xdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV || "",
|
||||
mpvDir: process.env.SESSION_MPV_DIR_ENV || "",
|
||||
logsDir: process.env.SESSION_LOGS_DIR_ENV || "",
|
||||
mpvLog: process.env.SESSION_MPV_LOG_ENV || "",
|
||||
},
|
||||
steps,
|
||||
};
|
||||
|
||||
const summaryJson = JSON.stringify(summary, null, 2) + "\n";
|
||||
fs.writeFileSync(path.join(artifactDir, "summary.json"), summaryJson);
|
||||
fs.writeFileSync(path.join(reportsDir, "summary.json"), summaryJson);
|
||||
|
||||
const lines = [];
|
||||
lines.push(`session_id: ${summary.sessionId}`);
|
||||
lines.push(`artifact_dir: ${artifactDir}`);
|
||||
lines.push(`selected_lanes: ${lanes.join(", ") || "(none)"}`);
|
||||
lines.push(`status: ${summary.status}`);
|
||||
lines.push(`path_selection_mode: ${summary.pathSelectionMode}`);
|
||||
if (requestedPaths.length > 0) {
|
||||
lines.push(`requested_paths: ${requestedPaths.join(", ")}`);
|
||||
}
|
||||
if (blockers.length > 0) {
|
||||
lines.push(`blockers: ${blockers.join(" | ")}`);
|
||||
}
|
||||
for (const step of steps) {
|
||||
lines.push(`${step.lane}/${step.name}: ${step.status}`);
|
||||
if (step.command) lines.push(` command: ${step.command}`);
|
||||
lines.push(` stdout: ${step.stdout}`);
|
||||
lines.push(` stderr: ${step.stderr}`);
|
||||
if (step.note) lines.push(` note: ${step.note}`);
|
||||
}
|
||||
if (summary.failed) {
|
||||
lines.push(`failure_command: ${process.env.FAILURE_COMMAND_ENV || ""}`);
|
||||
}
|
||||
const summaryText = lines.join("\n") + "\n";
|
||||
fs.writeFileSync(path.join(artifactDir, "summary.txt"), summaryText);
|
||||
fs.writeFileSync(path.join(reportsDir, "summary.txt"), summaryText);
|
||||
'
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
release_real_runtime_lease
|
||||
}
|
||||
|
||||
CLASSIFIER_OUTPUT=""
|
||||
ARTIFACT_DIR=""
|
||||
ALLOW_REAL_RUNTIME=0
|
||||
DRY_RUN=0
|
||||
FAILED=0
|
||||
BLOCKED=0
|
||||
EXECUTED_REAL_STEPS=0
|
||||
FINAL_STATUS=""
|
||||
FAILURE_STEP=""
|
||||
FAILURE_COMMAND=""
|
||||
FAILURE_STDOUT=""
|
||||
FAILURE_STDERR=""
|
||||
REAL_RUNTIME_LEASE_DIR=""
|
||||
STARTED_AT=""
|
||||
FINISHED_AT=""
|
||||
|
||||
declare -a EXPLICIT_LANES=()
|
||||
declare -a SELECTED_LANES=()
|
||||
declare -a PATH_ARGS=()
|
||||
declare -a COMMANDS_RUN=()
|
||||
declare -a BLOCKERS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--lane)
|
||||
EXPLICIT_LANES+=("$(normalize_lane_name "$2")")
|
||||
shift 2
|
||||
;;
|
||||
--artifact-dir)
|
||||
ARTIFACT_DIR=$2
|
||||
shift 2
|
||||
;;
|
||||
--allow-real-runtime|--allow-real-gui)
|
||||
ALLOW_REAL_RUNTIME=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
while [[ $# -gt 0 ]]; do
|
||||
PATH_ARGS+=("$1")
|
||||
shift
|
||||
done
|
||||
;;
|
||||
*)
|
||||
PATH_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||
SESSION_ID=$(generate_session_id)
|
||||
PATH_SELECTION_MODE="git-inferred"
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
PATH_SELECTION_MODE="explicit"
|
||||
fi
|
||||
|
||||
if [[ -z "$ARTIFACT_DIR" ]]; then
|
||||
mkdir -p "$REPO_ROOT/.tmp/skill-verification"
|
||||
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
|
||||
fi
|
||||
|
||||
SESSION_HOME="$ARTIFACT_DIR/home"
|
||||
SESSION_XDG_CONFIG_HOME="$ARTIFACT_DIR/xdg"
|
||||
SESSION_MPV_DIR="$ARTIFACT_DIR/mpv"
|
||||
SESSION_LOGS_DIR="$ARTIFACT_DIR/logs"
|
||||
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
|
||||
|
||||
mkdir -p "$ARTIFACT_DIR/steps" "$ARTIFACT_DIR/reports" "$SESSION_HOME" "$SESSION_XDG_CONFIG_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
|
||||
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
|
||||
: >"$STEPS_TSV"
|
||||
|
||||
trap cleanup EXIT
|
||||
STARTED_AT=$(timestamp_iso)
|
||||
|
||||
if [[ ${#EXPLICIT_LANES[@]} -gt 0 ]]; then
|
||||
local_lane=""
|
||||
for local_lane in "${EXPLICIT_LANES[@]}"; do
|
||||
add_lane "$local_lane"
|
||||
done
|
||||
printf 'reason:explicit lanes supplied\n' >"$ARTIFACT_DIR/classification.txt"
|
||||
else
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}")
|
||||
else
|
||||
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh")
|
||||
fi
|
||||
printf '%s\n' "$CLASSIFIER_OUTPUT" >"$ARTIFACT_DIR/classification.txt"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
lane:*)
|
||||
add_lane "${line#lane:}"
|
||||
;;
|
||||
esac
|
||||
done <<<"$CLASSIFIER_OUTPUT"
|
||||
fi
|
||||
|
||||
record_env
|
||||
|
||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||
printf 'selected_lanes=%s\n' "$(IFS=,; echo "${SELECTED_LANES[*]}")"
|
||||
|
||||
for lane in "${SELECTED_LANES[@]}"; do
|
||||
case "$lane" in
|
||||
docs)
|
||||
run_step "$lane" "docs-test" "bun run docs:test" || break
|
||||
[[ "$FAILED" == "1" ]] && break
|
||||
run_step "$lane" "docs-build" "bun run docs:build" || break
|
||||
;;
|
||||
config)
|
||||
run_step "$lane" "test-config" "bun run test:config" || break
|
||||
;;
|
||||
core)
|
||||
run_step "$lane" "typecheck" "bun run typecheck" || break
|
||||
[[ "$FAILED" == "1" ]] && break
|
||||
run_step "$lane" "test-fast" "bun run test:fast" || break
|
||||
;;
|
||||
launcher-plugin)
|
||||
run_step "$lane" "launcher-smoke-src" "bun run test:launcher:smoke:src" || break
|
||||
[[ "$FAILED" == "1" ]] && break
|
||||
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
|
||||
;;
|
||||
runtime-compat)
|
||||
run_step "$lane" "build" "bun run build" || break
|
||||
[[ "$FAILED" == "1" ]] && break
|
||||
run_step "$lane" "test-runtime-compat" "bun run test:runtime:compat" || break
|
||||
[[ "$FAILED" == "1" ]] && break
|
||||
run_step "$lane" "test-smoke-dist" "bun run test:smoke:dist" || break
|
||||
;;
|
||||
real-runtime)
|
||||
if [[ "$PATH_SELECTION_MODE" != "explicit" ]]; then
|
||||
record_blocked_step \
|
||||
"$lane" \
|
||||
"real-runtime-guard" \
|
||||
"real-runtime lane requires explicit paths; inferred local git changes are non-authoritative"
|
||||
break
|
||||
fi
|
||||
|
||||
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
|
||||
record_blocked_step \
|
||||
"$lane" \
|
||||
"real-runtime-guard" \
|
||||
"real-runtime lane requested but --allow-real-runtime was not supplied"
|
||||
break
|
||||
fi
|
||||
|
||||
if ! acquire_real_runtime_lease; then
|
||||
record_blocked_step \
|
||||
"$lane" \
|
||||
"real-runtime-lease" \
|
||||
"real-runtime lease already held; rerun after the active runtime verification finishes"
|
||||
break
|
||||
fi
|
||||
|
||||
if ! REAL_RUNTIME_HELPER=$(find_real_runtime_helper); then
|
||||
record_blocked_step \
|
||||
"$lane" \
|
||||
"real-runtime-helper" \
|
||||
"real-runtime helper not implemented yet"
|
||||
break
|
||||
fi
|
||||
|
||||
printf -v REAL_RUNTIME_COMMAND \
|
||||
'SESSION_ID=%q HOME=%q XDG_CONFIG_HOME=%q SUBMINER_MPV_LOG=%q bash %q' \
|
||||
"$SESSION_ID" \
|
||||
"$SESSION_HOME" \
|
||||
"$SESSION_XDG_CONFIG_HOME" \
|
||||
"$SESSION_MPV_LOG" \
|
||||
"$REAL_RUNTIME_HELPER"
|
||||
|
||||
run_step "$lane" "real-runtime-smoke" "$REAL_RUNTIME_COMMAND" || break
|
||||
;;
|
||||
*)
|
||||
record_failed_step "$lane" "lane-validation" "unknown lane: $lane"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$FAILED" == "1" || "$BLOCKED" == "1" ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
FINISHED_AT=$(timestamp_iso)
|
||||
compute_final_status
|
||||
write_summary_files
|
||||
|
||||
printf 'status=%s\n' "$FINAL_STATUS"
|
||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||
|
||||
case "$FINAL_STATUS" in
|
||||
failed)
|
||||
printf 'result=failed\n'
|
||||
printf 'failure_command=%s\n' "$FAILURE_COMMAND"
|
||||
exit 1
|
||||
;;
|
||||
blocked)
|
||||
printf 'result=blocked\n'
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
printf 'result=ok\n'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
146
.agents/skills/subminer-scrum-master/SKILL.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: "subminer-scrum-master"
|
||||
description: "Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff."
|
||||
---
|
||||
|
||||
# SubMiner Scrum Master
|
||||
|
||||
Own workflow, not code by default.
|
||||
|
||||
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Decide first whether backlog tracking is warranted.
|
||||
2. If backlog is needed, search first. Update existing work when it clearly matches.
|
||||
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
|
||||
4. Record a plan before dispatching coding work.
|
||||
5. Use parent + subtasks for multi-part work when backlog is used.
|
||||
6. Dispatch conservatively. Parallelize only disjoint write scopes.
|
||||
7. Require verification before handoff, typically via `subminer-change-verification`.
|
||||
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
|
||||
|
||||
## Backlog Decision
|
||||
|
||||
Skip backlog when the request is:
|
||||
- question only
|
||||
- obvious mechanical edit
|
||||
- tiny isolated change with no real planning
|
||||
|
||||
Use backlog when the work:
|
||||
- needs planning or scope decisions
|
||||
- spans multiple phases or subsystems
|
||||
- is likely to need subagent dispatch
|
||||
- should remain traceable for handoff/resume
|
||||
|
||||
If backlog is used:
|
||||
- search existing tasks first
|
||||
- create/update a standalone task for one focused deliverable
|
||||
- create/update a parent task plus subtasks for multi-part work
|
||||
- record the implementation plan in the task before implementation begins
|
||||
|
||||
## Intake Workflow
|
||||
|
||||
1. Parse the request.
|
||||
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
|
||||
2. Decide whether backlog is needed.
|
||||
3. If backlog is needed:
|
||||
- search first
|
||||
- update existing task if clearly relevant
|
||||
- otherwise create the right structure
|
||||
- write the implementation plan before dispatch
|
||||
4. If backlog is skipped:
|
||||
- write a short working plan in-thread
|
||||
- proceed without fake ticketing
|
||||
5. Choose execution mode:
|
||||
- no subagents for trivial work
|
||||
- one worker for focused work
|
||||
- parallel workers only for disjoint scopes
|
||||
6. Run verification before handoff.
|
||||
|
||||
## Dispatch Rules
|
||||
|
||||
The scrum master orchestrates. Workers implement.
|
||||
|
||||
- Do not become the default implementer unless delegation is unnecessary.
|
||||
- Do not parallelize overlapping files or tightly coupled runtime work.
|
||||
- Give every worker explicit ownership of files/modules.
|
||||
- Tell every worker other agents may be active and they must not revert unrelated edits.
|
||||
- Require each worker to report:
|
||||
- changed files
|
||||
- tests run
|
||||
- blockers
|
||||
|
||||
Use worker agents for implementation and explorer agents only for bounded codebase questions.
|
||||
|
||||
## Verification
|
||||
|
||||
Every nontrivial code task gets verification.
|
||||
|
||||
Preferred flow:
|
||||
1. use `subminer-change-verification`
|
||||
2. start with the cheapest sufficient lane
|
||||
3. escalate only when needed
|
||||
4. if worker verification is sufficient, accept it or run one final consolidating pass
|
||||
|
||||
Never hand off nontrivial work without stating what was verified and what was skipped.
|
||||
|
||||
## Pre-Handoff Policy Checks (Required)
|
||||
|
||||
Before handoff, always ask and answer both of these questions explicitly:
|
||||
|
||||
1. **Docs update required?**
|
||||
2. **Changelog fragment required?**
|
||||
|
||||
Rules:
|
||||
- Do not assume silence implies "no." Record an explicit yes/no decision for each item.
|
||||
- If the answer is yes, either complete the update or report the blocker before handoff.
|
||||
- Include the final answers in the handoff summary even when both answers are "no."
|
||||
|
||||
## Failure / Scope Handling
|
||||
|
||||
- If a worker hits ambiguity, pause and ask the user.
|
||||
- If verification fails, either:
|
||||
- send the worker back with exact failure context, or
|
||||
- fix it directly if it is tiny and clearly in scope
|
||||
- If new scope appears, revisit backlog structure before silently expanding work.
|
||||
|
||||
## Representative Flows
|
||||
|
||||
### Trivial no-ticket work
|
||||
|
||||
- decide backlog is unnecessary
|
||||
- keep a short plan
|
||||
- implement directly or with one worker if helpful
|
||||
- run targeted verification
|
||||
- report outcome concisely
|
||||
|
||||
### Single-task implementation
|
||||
|
||||
- search/create/update one task
|
||||
- record plan
|
||||
- dispatch one worker
|
||||
- integrate
|
||||
- verify
|
||||
- update task and report outcome
|
||||
|
||||
### Parent + subtasks execution
|
||||
|
||||
- search/create/update parent task
|
||||
- create subtasks for distinct deliverables/phases
|
||||
- record sequencing in the plan
|
||||
- dispatch workers only where scopes are disjoint
|
||||
- integrate
|
||||
- run consolidated verification
|
||||
- update task state and report outcome
|
||||
|
||||
## Output Expectations
|
||||
|
||||
At the end, report:
|
||||
- whether backlog was used and what changed
|
||||
- which workers were dispatched and what they owned
|
||||
- what verification ran
|
||||
- explicit answers to:
|
||||
- docs update required?
|
||||
- changelog fragment required?
|
||||
- blockers, skips, and risks
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -27,13 +27,16 @@ jobs:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Lint changelog fragments
|
||||
run: bun run changelog:lint
|
||||
@@ -49,6 +52,9 @@ jobs:
|
||||
- name: Verify generated config examples
|
||||
run: bun run verify:config-example
|
||||
|
||||
- name: Internal docs knowledge-base checks
|
||||
run: bun run test:docs:kb
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
|
||||
93
.github/workflows/release.yml
vendored
93
.github/workflows/release.yml
vendored
@@ -9,9 +9,6 @@ concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -244,6 +241,8 @@ jobs:
|
||||
release:
|
||||
needs: [build-linux, build-macos, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -318,7 +317,7 @@ jobs:
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify changelog is ready for tagged release
|
||||
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||
@@ -363,3 +362,89 @@ jobs:
|
||||
for asset in "${artifacts[@]}"; do
|
||||
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||
done
|
||||
|
||||
aur-publish:
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate AUR SSH secret
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install makepkg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y makepkg
|
||||
|
||||
- name: Configure SSH for AUR
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -dm700 ~/.ssh
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
|
||||
- name: Clone AUR repo
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
|
||||
|
||||
- name: Download release assets for AUR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ steps.version.outputs.VERSION }}"
|
||||
install -dm755 .tmp/aur-release-assets
|
||||
gh release download "$version" \
|
||||
--dir .tmp/aur-release-assets \
|
||||
--pattern "SubMiner-${version#v}.AppImage" \
|
||||
--pattern "subminer" \
|
||||
--pattern "subminer-assets.tar.gz"
|
||||
|
||||
- name: Update AUR packaging metadata
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_no_v="${{ steps.version.outputs.VERSION }}"
|
||||
version_no_v="${version_no_v#v}"
|
||||
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
|
||||
bash scripts/update-aur-package.sh \
|
||||
--pkg-dir aur-subminer-bin \
|
||||
--version "${{ steps.version.outputs.VERSION }}" \
|
||||
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
|
||||
--wrapper ".tmp/aur-release-assets/subminer" \
|
||||
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
|
||||
|
||||
- name: Commit and push AUR update
|
||||
working-directory: aur-subminer-bin
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- PKGBUILD .SRCINFO; then
|
||||
echo "AUR packaging already up to date."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
|
||||
git push origin HEAD:master
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -35,6 +35,20 @@ docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
tests/*
|
||||
.worktrees/
|
||||
.tmp/
|
||||
.codex/*
|
||||
.agents/*
|
||||
!.agents/skills/
|
||||
.agents/skills/*
|
||||
!.agents/skills/subminer-change-verification/
|
||||
!.agents/skills/subminer-scrum-master/
|
||||
.agents/skills/subminer-change-verification/*
|
||||
!.agents/skills/subminer-change-verification/SKILL.md
|
||||
!.agents/skills/subminer-change-verification/scripts/
|
||||
.agents/skills/subminer-change-verification/scripts/*
|
||||
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||
.agents/skills/subminer-scrum-master/*
|
||||
!.agents/skills/subminer-scrum-master/SKILL.md
|
||||
favicon.png
|
||||
!stats/public/favicon.png
|
||||
|
||||
92
AGENTS.md
92
AGENTS.md
@@ -1,17 +1,29 @@
|
||||
# AGENTS.MD
|
||||
|
||||
## Internal Docs
|
||||
|
||||
Start here, then leave this file.
|
||||
|
||||
- Internal system of record: [`docs/README.md`](./docs/README.md)
|
||||
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
||||
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
||||
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
||||
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
||||
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
||||
|
||||
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
|
||||
|
||||
## Quick Start
|
||||
|
||||
- Read [`docs-site/development.md`](./docs-site/development.md) and [`docs-site/architecture.md`](./docs-site/architecture.md) before substantial changes; follow them unless task requires deviation.
|
||||
- Init workspace: `git submodule update --init --recursive`.
|
||||
- Install deps: `make deps` or `bun install` plus `(cd vendor/texthooker-ui && bun install --frozen-lockfile)`.
|
||||
- Fast dev loop: `make dev-watch`.
|
||||
- Full local run: `bun run dev`.
|
||||
- Verbose Electron debug: `electron . --start --dev --log-level debug`.
|
||||
- Init workspace: `git submodule update --init --recursive`
|
||||
- Install deps: `make deps` or `bun install` plus `(cd vendor/texthooker-ui && bun install --frozen-lockfile)`
|
||||
- Fast dev loop: `make dev-watch`
|
||||
- Full local run: `bun run dev`
|
||||
- Verbose Electron debug: `electron . --start --dev --log-level debug`
|
||||
|
||||
## Build / Test
|
||||
|
||||
- Use repo package manager/runtime only: Bun (`packageManager: bun@1.3.5`).
|
||||
- Runtime/package manager: Bun (`packageManager: bun@1.3.5`)
|
||||
- Default handoff gate:
|
||||
`bun run typecheck`
|
||||
`bun run test:fast`
|
||||
@@ -21,59 +33,37 @@
|
||||
- If `docs-site/` changed, also run:
|
||||
`bun run docs:test`
|
||||
`bun run docs:build`
|
||||
- Formatting: prefer `make pretty` and `bun run format:check:src`; use `bun run format` only intentionally.
|
||||
- Keep verification observable; capture failing command + exact error in notes/handoff.
|
||||
- Prefer `make pretty` and `bun run format:check:src`
|
||||
|
||||
## Change-Specific Checks
|
||||
|
||||
- Config/schema/defaults changes: run `bun run test:config`; if config template/defaults changed, run `bun run generate:config-example`.
|
||||
- Launcher/plugin changes: run `bun run test:launcher` or `bun run test:env`; use `bun run test:launcher:smoke:src` for focused launcher e2e checks.
|
||||
- Runtime-compat or compiled/dist-sensitive changes: run `bun run test:runtime:compat`.
|
||||
- Docs-only changes: at least `bun run docs:test` if docs behavior/assertions changed; `bun run docs:build` before handoff.
|
||||
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||
|
||||
## Generated / Sensitive Files
|
||||
## Sensitive Files
|
||||
|
||||
- Launcher source of truth: `launcher/*.ts`.
|
||||
- Generated launcher artifact: `dist/launcher/subminer`; never hand-edit it.
|
||||
- Repo-root `./subminer` is stale artifact path; do not revive/use it.
|
||||
- `bun run build` rebuilds bundled Yomitan from `vendor/subminer-yomitan`; check submodules before debugging build failures.
|
||||
- Avoid changing packaging/signing identifiers (`build.appId`, mac entitlements, signing-related settings) unless task explicitly requires it.
|
||||
- Launcher source of truth: `launcher/*.ts`
|
||||
- Generated launcher artifact: `dist/launcher/subminer`; never hand-edit it
|
||||
- Repo-root `./subminer` is stale; do not revive it
|
||||
- `bun run build` rebuilds bundled Yomitan from `vendor/subminer-yomitan`
|
||||
- Do not change signing/packaging identifiers unless the task explicitly requires it
|
||||
|
||||
## Docs
|
||||
## Release / PR Notes
|
||||
|
||||
- Docs site lives in-repo under [`docs-site/`](./docs-site/).
|
||||
- Update docs for new/breaking behavior; no ship with stale docs.
|
||||
- Make sure [`docs-site/changelog.md`](./docs-site/changelog.md) is updated on each release.
|
||||
- User-visible PRs need one fragment in `changes/*.md`
|
||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||
- PR review helpers:
|
||||
- `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
|
||||
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
|
||||
|
||||
## PR Feedback
|
||||
## Runtime Notes
|
||||
|
||||
- Active PR: `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`.
|
||||
- PR comments: `gh pr view …` + `gh api …/comments --paginate`.
|
||||
- Replies: cite fix + file/line; resolve threads only after fix lands.
|
||||
|
||||
## Changelog
|
||||
|
||||
- User-visible PRs: add one fragment in `changes/*.md`.
|
||||
- Fragment format:
|
||||
`type: added|changed|fixed|docs|internal`
|
||||
`area: <short-area>`
|
||||
blank line
|
||||
`- bullet`
|
||||
- `changes/README.md`: instructions only; generator ignores it.
|
||||
- No release-note entry wanted: use PR label `skip-changelog`.
|
||||
- CI runs `bun run changelog:lint` + `bun run changelog:pr-check` on PRs.
|
||||
- Release prep: `bun run changelog:build`, review `CHANGELOG.md` + `release/release-notes.md`, commit generated changelog + fragment deletions, then tag.
|
||||
- Release CI expects committed changelog entry already present; do not rely on tag job to invent notes.
|
||||
|
||||
## Flow & Runtime
|
||||
|
||||
- Use Codex background for long jobs; tmux only for interactive/persistent (debugger/server).
|
||||
- CI red: `gh run list/view`, rerun, fix, push, repeat til green.
|
||||
|
||||
## Language/Stack Notes
|
||||
|
||||
- Swift: use workspace helper/daemon; validate `swift build` + tests; keep concurrency attrs right.
|
||||
- TypeScript: use repo PM; keep files small; follow existing patterns.
|
||||
- Use Codex background for long jobs; tmux only when persistence/interaction is required
|
||||
- CI red: `gh run list/view`, rerun, fix, repeat until green
|
||||
- TypeScript: keep files small; follow existing patterns
|
||||
- Swift: use workspace helper/daemon; validate `swift build` + tests
|
||||
|
||||
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
||||
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -1,6 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.0 (2026-03-12)
|
||||
## v0.6.2 (2026-03-12)
|
||||
|
||||
### Changed
|
||||
- Config: Added `yomitan.externalProfilePath` to reuse another Electron app's Yomitan profile in read-only mode.
|
||||
- Config: SubMiner now reuses external Yomitan dictionaries/settings without writing back to that profile.
|
||||
- Config: Launcher-managed playback now respects `yomitan.externalProfilePath` and no longer forces first-run setup when external Yomitan is configured.
|
||||
- Config: SubMiner now seeds `config.jsonc` even when the default config directory already exists.
|
||||
- Config: First-run setup now allows zero internal dictionaries when `yomitan.externalProfilePath` is configured, and falls back to requiring at least one internal dictionary if that external profile is later removed.
|
||||
|
||||
## v0.6.1 (2026-03-12)
|
||||
|
||||
### Added
|
||||
- Overlay: Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||
@@ -8,8 +17,12 @@
|
||||
- Overlay: Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Overlay: Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||
|
||||
### Docs
|
||||
- Install: Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
|
||||
|
||||
### Internal
|
||||
- Config: add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||
- Release: Fixed the release workflow token permissions so tagged builds can download `oven-sh/setup-bun` and publish artifacts again.
|
||||
|
||||
## v0.5.6 (2026-03-10)
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -69,7 +69,7 @@ help:
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
"Other targets:" \
|
||||
" deps Install JS dependencies (root + texthooker-ui)" \
|
||||
" deps Install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" uninstall-linux Remove Linux install artifacts" \
|
||||
" uninstall-macos Remove macOS install artifacts" \
|
||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||
@@ -104,6 +104,7 @@ print-dirs:
|
||||
deps:
|
||||
@$(MAKE) --no-print-directory ensure-bun
|
||||
@bun install
|
||||
@cd stats && bun install --frozen-lockfile
|
||||
@cd vendor/texthooker-ui && bun install --frozen-lockfile
|
||||
|
||||
ensure-bun:
|
||||
|
||||
@@ -27,6 +27,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
|
||||
- **Look up words as you watch** — Yomitan dictionary popups on hover or keyboard-driven token-by-token navigation
|
||||
- **One-key Anki mining** — Creates cards with sentence, audio, screenshot, and translation; optional local AnkiConnect proxy auto-enriches Yomitan cards instantly
|
||||
- **Reading annotations** — N+1 targeting, frequency-dictionary highlighting, JLPT underlining, and character name dictionary for anime/manga proper nouns
|
||||
- **Immersion stats** — Optional local dashboard and overlay for watch time, anime progress, session drill-down, vocabulary growth, and mining throughput
|
||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||
- **Jellyfin & AniList integration** — Remote playback, cast device mode, and automatic episode progress tracking
|
||||
- **Texthooker & API** — Built-in texthooker page and annotated websocket feed for external clients
|
||||
@@ -101,6 +102,7 @@ The mpv plugin step is optional. Yomitan must report at least one installed dict
|
||||
```bash
|
||||
subminer video.mkv # default plugin config auto-starts visible overlay + resumes playback when ready
|
||||
subminer --start video.mkv # optional explicit overlay start when plugin auto_start=no
|
||||
subminer stats # open the local stats dashboard in your browser
|
||||
```
|
||||
|
||||
## Requirements
|
||||
@@ -118,7 +120,7 @@ Windows builds use native window tracking and do not require the Linux composito
|
||||
|
||||
## Documentation
|
||||
|
||||
For full guides on configuration, Anki, Jellyfin, and more, see [docs.subminer.moe](https://docs.subminer.moe). The VitePress source for that site lives in [`docs-site/`](./docs-site/).
|
||||
For full guides on configuration, Anki, Jellyfin, immersion tracking/stats, and more, see [docs.subminer.moe](https://docs.subminer.moe). The VitePress source for that site lives in [`docs-site/`](./docs-site/).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: TASK-165
|
||||
title: Automate AUR publish on tagged releases
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-14 15:55'
|
||||
updated_date: '2026-03-14 18:40'
|
||||
labels:
|
||||
- release
|
||||
- packaging
|
||||
- linux
|
||||
dependencies:
|
||||
- TASK-161
|
||||
references:
|
||||
- .github/workflows/release.yml
|
||||
- src/release-workflow.test.ts
|
||||
- docs/RELEASING.md
|
||||
- packaging/aur/subminer-bin/PKGBUILD
|
||||
documentation:
|
||||
- docs/plans/2026-03-14-aur-release-sync-design.md
|
||||
- docs/plans/2026-03-14-aur-release-sync.md
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Extend the tagged release workflow so a successful GitHub release automatically updates the `subminer-bin` AUR package over SSH. Keep the PKGBUILD source-of-truth in this repo so release automation is reviewable and testable instead of depending on an external maintainer checkout.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Repo-tracked AUR packaging source exists for `subminer-bin` and matches the current release artifact layout.
|
||||
- [x] #2 The release workflow clones `ssh://aur@aur.archlinux.org/subminer-bin.git` with a dedicated secret-backed SSH key only after release artifacts are ready.
|
||||
- [x] #3 The workflow updates `pkgver`, regenerates `sha256sums` from the built release artifacts, regenerates `.SRCINFO`, and pushes only when packaging files changed.
|
||||
- [x] #4 Regression coverage fails if the AUR publish job, secret contract, or update steps are removed from the release workflow.
|
||||
- [x] #5 Release docs mention the required `AUR_SSH_PRIVATE_KEY` setup and the new tagged-release side effect.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Record the approved design and implementation plan for direct AUR publishing from the release workflow.
|
||||
2. Add failing release workflow regression tests covering the new AUR publish job, SSH secret, and PKGBUILD/.SRCINFO regeneration steps.
|
||||
3. Reintroduce repo-tracked `packaging/aur/subminer-bin` source files as the maintained AUR template.
|
||||
4. Add a small helper script that updates `pkgver`, computes checksums from release artifacts, and regenerates `.SRCINFO` deterministically.
|
||||
5. Extend `.github/workflows/release.yml` with an AUR publish job that clones the AUR repo over SSH, runs the helper, commits only when needed, and pushes to `aur`.
|
||||
6. Update release docs for the new secret/setup requirements and tagged-release behavior.
|
||||
7. Run targeted workflow tests plus the SubMiner verification lane needed for workflow/docs changes, then update this task with results.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added repo-tracked AUR packaging source under `packaging/aur/subminer-bin/` plus `scripts/update-aur-package.sh` to stamp `pkgver`, compute SHA-256 sums from release assets, and regenerate `.SRCINFO` with `makepkg --printsrcinfo`.
|
||||
|
||||
Extended `.github/workflows/release.yml` with a terminal `aur-publish` job that runs after `release`, validates `AUR_SSH_PRIVATE_KEY`, installs `makepkg`, configures SSH/known_hosts, clones `ssh://aur@aur.archlinux.org/subminer-bin.git`, downloads the just-published `SubMiner-<version>.AppImage`, `subminer`, and `subminer-assets.tar.gz` assets, updates packaging metadata, and pushes only when `PKGBUILD` or `.SRCINFO` changed.
|
||||
|
||||
Updated `src/release-workflow.test.ts` with regression assertions for the AUR publish contract and updated `docs/RELEASING.md` with the new secret/setup requirement.
|
||||
|
||||
Verification run:
|
||||
- `bun test src/release-workflow.test.ts src/ci-workflow.test.ts`
|
||||
- `bash -n scripts/update-aur-package.sh && bash -n packaging/aur/subminer-bin/PKGBUILD`
|
||||
- `cd packaging/aur/subminer-bin && makepkg --printsrcinfo > .SRCINFO`
|
||||
- updater smoke via temp package dir with fake assets and `v9.9.9`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `git submodule update --init --recursive` (required because the worktree lacked release submodules)
|
||||
- `bun run build`
|
||||
- `bun run test:smoke:dist`
|
||||
|
||||
Docs update required: yes, completed in `docs/RELEASING.md`.
|
||||
Changelog fragment required: no; internal release automation only.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Tagged releases now attempt a direct AUR sync for `subminer-bin` using a dedicated SSH private key stored in `AUR_SSH_PRIVATE_KEY`. The release workflow clones the AUR repo after GitHub Release publication, rewrites `PKGBUILD` and `.SRCINFO` from the published release assets, and skips empty pushes. Repo-owned packaging source and workflow regression coverage were added so the automation remains reviewable and testable.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: TASK-165
|
||||
title: Rewrite SubMiner agentic testing automation plan
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-13 04:45'
|
||||
updated_date: '2026-03-13 04:47'
|
||||
labels:
|
||||
- planning
|
||||
- testing
|
||||
- agents
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/testing-plan.md
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/SKILL.md
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-scrum-master/SKILL.md
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs-site/development.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs-site/architecture.md
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replace the current generic Electron/mpv testing plan with a SubMiner-specific plan that uses the existing skills as the source of truth, treats real launcher/plugin/mpv runtime verification as primary, and defines a non-interference contract for parallel agent work.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `testing-plan.md` is rewritten for SubMiner rather than a generic Electron+mpv app
|
||||
- [x] #2 The plan keeps `subminer-scrum-master` and `subminer-change-verification` as the primary orchestration and verification entrypoints
|
||||
- [x] #3 The plan defines real launcher/plugin/mpv runtime verification as the authoritative lane for runtime bug claims
|
||||
- [x] #4 The plan defines explicit session isolation and non-interference rules for parallel agent work
|
||||
- [x] #5 The plan defines artifact/reporting expectations and phased rollout, with synthetic/headless verification clearly secondary to real-runtime verification
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Review the existing testing plan and compare it against current SubMiner architecture, verification lanes, and skills.
|
||||
2. Replace the generic Electron/mpv harness framing with a SubMiner-specific control plane centered on existing skills.
|
||||
3. Define the authoritative real-runtime lane, session isolation rules, concurrency classes, and reporting contract.
|
||||
4. Sanity-check the rewritten document against current repo docs and skill contracts before handoff.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Rewrote `testing-plan.md` around existing `subminer-scrum-master` and `subminer-change-verification` responsibilities instead of proposing a competing new top-level testing skill.
|
||||
|
||||
Set real launcher/plugin/mpv/runtime verification as the authoritative lane for runtime bug claims and made synthetic/headless verification explicitly secondary.
|
||||
|
||||
Defined session-scoped paths, unique mutable resources, concurrency classes, and an exclusive lease for conflicting real-runtime verification to prevent parallel interference.
|
||||
|
||||
Sanity-checked the final document by inspecting the rewritten file content and diff.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Rewrote `testing-plan.md` into a SubMiner-specific agentic verification plan. The new document keeps `subminer-scrum-master` and `subminer-change-verification` as the primary orchestration and verification entrypoints, treats the real launcher/plugin/mpv/runtime path as authoritative for runtime bug claims, and defines a hard non-interference contract for parallel work through session isolation and an exclusive real-runtime lease. The plan now also includes an explicit reporting schema, capture policy, phased rollout, and a clear statement that true parallel full-app instances are not a phase-1 requirement. Verification for this task was a document sanity pass against the current repo docs, skills, and the resulting file diff.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
id: TASK-166
|
||||
title: Harden SubMiner change verification for authoritative agentic runtime checks
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-13 05:19'
|
||||
updated_date: '2026-03-13 05:36'
|
||||
labels:
|
||||
- testing
|
||||
- agents
|
||||
- verification
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/SKILL.md
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/testing-plan.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs-site/development.md
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Tighten the SubMiner change-verification classifier and verifier so the implementation matches the approved agentic verification plan: authoritative runtime verification must fail closed when unavailable, lane naming should use real-runtime semantics, session and artifact identities must be unique, and the verifier must be safer for parallel agent use.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The verifier uses `real-runtime` terminology instead of `real-gui` for authoritative runtime verification
|
||||
- [x] #2 Requested authoritative runtime verification fails closed with a non-green outcome when it cannot run, and unknown lanes do not pass open
|
||||
- [x] #3 The verifier allocates a unique session identifier and artifact root that does not rely on second-level timestamp uniqueness alone
|
||||
- [x] #4 The verifier summary/report output includes explicit top-level status and session metadata needed for agent aggregation
|
||||
- [x] #5 The classifier and verifier better reflect runtime-escalation cases for launcher/plugin/socket/runtime-sensitive changes
|
||||
- [x] #6 Regression tests cover the new verifier/classifier behavior
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add regression tests for classifier/verifier behavior before changing the scripts.
|
||||
2. Harden `verify_subminer_change.sh` to use `real-runtime` terminology, fail closed for blocked/unknown authoritative verification, and emit unique session metadata in summaries.
|
||||
3. Update `classify_subminer_diff.sh` and the skill doc to use `real-runtime` escalation language and better flag launcher/plugin/runtime-sensitive paths.
|
||||
4. Run targeted regression tests plus a focused dry-run verifier check, then record outcomes and blockers in the task.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added `scripts/subminer-change-verification.test.ts` to regression-test classifier/verifier behavior around `real-runtime` naming, fail-closed authoritative verification, unknown lanes, and unique session metadata.
|
||||
|
||||
Reworked `verify_subminer_change.sh` to normalize `real-gui` to `real-runtime`, emit unique session IDs and richer summary metadata, block authoritative runtime verification when unavailable, and fail closed for unknown lanes.
|
||||
|
||||
Updated `classify_subminer_diff.sh` to emit `real-runtime-candidate` for launcher/plugin/runtime-sensitive paths, and updated the active skill doc wording to match the new lane terminology.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Hardened the SubMiner change-verification tooling to match the approved agentic verification plan. The verifier now uses `real-runtime` terminology for authoritative runtime verification, preserves compatibility with the deprecated `real-gui` alias, fails closed for unknown lanes, and returns a blocked non-green outcome when requested authoritative runtime verification cannot run. It now allocates a unique session ID and artifact root by default, writes richer session metadata and top-level status into `summary.json`/`summary.txt` plus `reports/summary.*`, and records path selection mode, blockers, and session-local env roots for agent aggregation. The classifier now emits `real-runtime-candidate` for launcher/plugin/runtime-sensitive paths, and the active skill doc uses the same terminology. Verification ran via `bun test scripts/subminer-change-verification.test.ts`, direct dry-run smoke checks for blocked `real-runtime` and failed unknown-lane execution, and a targeted classifier invocation for launcher/plugin paths.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: TASK-167
|
||||
title: Track shared SubMiner agent skills in git and clean up ignore rules
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-13 05:46'
|
||||
updated_date: '2026-03-13 05:47'
|
||||
labels:
|
||||
- git
|
||||
- agents
|
||||
- repo-hygiene
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/.gitignore
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-change-verification/SKILL.md
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/.agents/skills/subminer-scrum-master/SKILL.md
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/testing-plan.md
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Adjust the repository ignore rules so the shared SubMiner agent skill files can be committed while keeping unrelated local agent state ignored. Also ensure generated local verification artifacts like `.tmp/` do not pollute git status.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Root ignore rules allow the shared SubMiner skill files under `.agents/skills/` to be tracked without broadly unignoring local agent state
|
||||
- [x] #2 The changed shared skill files appear in git status as trackable files after the ignore update
|
||||
- [x] #3 Local generated verification artifact directories remain ignored so git status stays clean
|
||||
- [x] #4 The updated ignore rules are minimal and scoped to the repo-shared skill files
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Updated `.gitignore` to keep `.agents` ignored by default while narrowly unignoring the repo-shared SubMiner skill files and verifier scripts.
|
||||
|
||||
Added `.tmp/` to the root ignore rules so local verification artifacts stop polluting `git status`.
|
||||
|
||||
Verified the result with `git status --untracked-files=all` and `git check-ignore -v`, confirming the shared skill files are now trackable and `.tmp/` remains ignored.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Adjusted the root `.gitignore` so the shared SubMiner agent skill files can be committed cleanly without broadly unignoring local agent state. The repo now tracks the shared `subminer-change-verification` skill files and the `subminer-scrum-master` skill doc, while `.tmp/` is ignored so generated verification artifacts do not pollute git status. Verified with `git status --untracked-files=all` and `git check-ignore -v` that the intended skill files are commit-ready and `.tmp/` remains ignored.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: TASK-168
|
||||
title: Document immersion stats dashboard and config
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-12 22:53'
|
||||
updated_date: '2026-03-12 22:53'
|
||||
labels:
|
||||
- docs
|
||||
- immersion
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
Refresh user-facing docs for the new immersion stats dashboard so README, docs-site pages, changelog notes, and generated config examples describe how to access the dashboard and which `stats.*` settings control it.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 README mentions the new stats surface in product-facing feature/docs copy.
|
||||
- [x] #2 Docs explain how to access the stats dashboard in-app and via localhost, and document the `stats` config block.
|
||||
- [x] #3 Changelog/release-note input includes the new stats dashboard.
|
||||
- [x] #4 Generated config examples include the new `stats` section.
|
||||
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
Updated README and the docs-site immersion/config/mining/shortcut/homepage copy to describe the new stats dashboard, including the overlay toggle (`stats.toggleKey`, default `Backquote`) and the localhost browser UI (`http://127.0.0.1:5175` by default).
|
||||
|
||||
Added a changelog fragment for the stats dashboard release notes and extended the config template sections so regenerated `config.example.jsonc` artifacts now include the `stats` block.
|
||||
|
||||
Verified with `bun run test:config`, `bun run generate:config-example`, `bun run docs:test`, `bun run docs:build`, and `bun run changelog:lint`.
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: TASK-169
|
||||
title: Add anime-level immersion metadata and link videos
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-13 19:34'
|
||||
updated_date: '2026-03-13 21:46'
|
||||
labels:
|
||||
- immersion
|
||||
- stats
|
||||
- database
|
||||
- anilist
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-13-immersion-anime-metadata-design.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-13-immersion-anime-metadata.md
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add first-class anime metadata to the immersion tracker so stats can group sessions and videos by anime, season, and episode instead of relying only on per-video canonical titles. The new model should deduplicate anime-level metadata across rewatches and multiple files, use guessit-first filename parsing with built-in parser fallback, and create provisional anime rows even when AniList lookup fails.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The immersion schema includes a new anime-level table plus additive video linkage/parsed metadata fields needed for anime, season, and episode stats.
|
||||
- [x] #2 Media ingest creates or reuses anime rows, stores parsed season/episode metadata on videos, and upgrades provisional anime rows when AniList data becomes available.
|
||||
- [x] #3 Query surfaces expose anime-level aggregation suitable for library/detail/episode stats without breaking current video/session queries.
|
||||
- [x] #4 Focused regression coverage exists for schema/storage/query/service behavior, including provisional anime rows and guessit-first parser fallback behavior.
|
||||
- [x] #5 Verification covers the SQLite immersion lane and any broader lanes required by the touched runtime/query files.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add red tests for the new schema shape in the SQLite immersion lane before changing storage code.
|
||||
2. Implement `imm_anime` plus additive `imm_videos` metadata fields and focused storage helpers for provisional anime creation and AniList upgrade.
|
||||
3. Add a guessit-first parser helper with built-in fallback and wire media ingest to persist anime/video metadata during `handleMediaChange(...)`.
|
||||
4. Add anime-level query surfaces for library/detail/episode aggregation and expose them only where needed.
|
||||
5. Run focused SQLite verification first, then broader verification lanes only if touched runtime/API files require them.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-13: Design approved in-thread. Initial scope excluded migration/backfill work, but implementation was corrected in-thread to add a legacy DB migration/backfill path based on filename parsing.
|
||||
2026-03-13: Detailed implementation plan written at `docs/plans/2026-03-13-immersion-anime-metadata.md`.
|
||||
2026-03-13: Task 6 export/API work was intentionally skipped because no current stats API/UI consumer needs the anime query surface yet, and widening the contract would have touched unrelated dirty stats files.
|
||||
2026-03-13: Verification commands run:
|
||||
- `bun test src/core/services/immersion-tracker/storage-session.test.ts`
|
||||
- `bun test src/core/services/immersion-tracker/metadata.test.ts`
|
||||
- `bun test src/core/services/immersion-tracker-service.test.ts`
|
||||
- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
- `bun run test:immersion:sqlite:src`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/storage.ts src/core/services/immersion-tracker/storage-session.test.ts src/core/services/immersion-tracker/metadata.ts src/core/services/immersion-tracker/metadata.test.ts src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker/types.ts src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker-service.ts src/core/services/immersion-tracker-service.test.ts`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/storage.ts src/core/services/immersion-tracker/storage-session.test.ts src/core/services/immersion-tracker/metadata.ts src/core/services/immersion-tracker/metadata.test.ts src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker/types.ts src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker-service.ts src/core/services/immersion-tracker-service.test.ts`
|
||||
2026-03-13: Verification results:
|
||||
- `bun run test:immersion:sqlite:src`: passed
|
||||
- verifier lane selection: `core`
|
||||
- verifier result: passed (`bun run typecheck`, `bun run test:fast`)
|
||||
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260313-214533-Ciw3L0/`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
Added `imm_anime`, additive `imm_videos` anime/parser metadata fields, and a legacy migration/backfill path that links existing videos to provisional anime rows from parsed filenames.
|
||||
|
||||
Added focused storage helpers for normalized anime identity reuse, later AniList upgrades, and per-video season/episode/parser metadata linking. Media ingest now parses and links anime metadata during `handleMediaChange(...)`.
|
||||
|
||||
Added anime-level query surfaces for library/detail/episode aggregation and regression coverage for schema, migration, storage, parser fallback, service ingest wiring, and anime stats queries.
|
||||
|
||||
Verified with the focused SQLite lane plus verifier-selected `core` coverage (`typecheck`, `test:fast`). No stats API/UI export was added yet because there is no current consumer for the new anime query surface.
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: TASK-170
|
||||
title: 'Fix imm_words POS filtering and add stats cleanup maintenance command'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-13 00:00'
|
||||
updated_date: '2026-03-14 18:31'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 9010
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
|
||||
`imm_words` is currently populated from raw subtitle text instead of tokenized subtitle metadata, so ignored functional/noise tokens leak into stats and no POS metadata is stored. Fix live persistence to follow the existing token annotation exclusion rules and add an on-demand stats cleanup command to remove stale bad vocabulary rows from the stats DB.
|
||||
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
|
||||
- [x] #1 New `imm_words` inserts use tokenized subtitle data, persist POS metadata, and skip tokens excluded by existing POS-based vocabulary ignore rules.
|
||||
- [x] #2 `subminer stats cleanup` supports `-v` / `--vocab`, defaults to vocab cleanup, and removes stale bad `imm_words` rows on demand.
|
||||
- [x] #3 Regression coverage exists for persistence filtering, cleanup behavior, and stats cleanup CLI wiring.
|
||||
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
|
||||
Fixed `imm_words` persistence so the tracker now consumes tokenized subtitle data, stores POS metadata (`part_of_speech`, `pos1`, `pos2`, `pos3`), preserves distinct surface/lemma fields (`word` vs `headword`) when tokenization provides them, and skips vocabulary rows excluded by the existing POS/noise rules instead of mining raw subtitle fragments. Added `subminer stats cleanup` with default vocab cleanup plus `-v/--vocab`; the cleanup pass now repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still have no usable POS metadata or fail the vocab filters.
|
||||
|
||||
Verification:
|
||||
|
||||
- `bun run typecheck`
|
||||
- `bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/storage-session.test.ts launcher/parse-args.test.ts launcher/commands/command-modules.test.ts src/main/runtime/stats-cli-command.test.ts src/main/runtime/mpv-main-event-main-deps.test.ts src/core/services/cli-command.test.ts`
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
id: TASK-171
|
||||
title: Add normalized immersion word and kanji occurrence tracking
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-14 11:30'
|
||||
updated_date: '2026-03-14 11:48'
|
||||
labels:
|
||||
- immersion
|
||||
- stats
|
||||
- database
|
||||
dependencies: []
|
||||
references:
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-14-immersion-occurrence-tracking-design.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs/plans/2026-03-14-immersion-occurrence-tracking.md
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add normalized occurrence tables for immersion-tracked words and kanji so stats can map vocabulary back to the exact anime, episode, timestamp, and subtitle line where each item appeared. Preserve repeated tokens within the same line via counted occurrences instead of deduping, while avoiding duplicated token text storage.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The immersion schema adds normalized subtitle-line and counted occurrence tables for words and kanji, with additive migration support for existing databases.
|
||||
- [x] #2 Subtitle-line tracking writes one subtitle-line row per seen line plus counted word/kanji occurrences linked back to the line, session, video, and anime context.
|
||||
- [x] #3 Query surfaces can map a word or kanji back to anime/episode/line/timestamp rows without breaking current top-level vocabulary and kanji stats.
|
||||
- [x] #4 Focused regression coverage exists for schema, counted occurrence persistence, and reverse-mapping queries.
|
||||
- [x] #5 Verification covers the SQLite immersion lane and any broader lanes required by touched service/API files.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add red tests for new line/occurrence schema and migration shape in the SQLite immersion lane.
|
||||
2. Add red tests for service-level subtitle persistence that writes one line row plus counted word/kanji occurrences.
|
||||
3. Implement additive schema, write-path plumbing, and counted occurrence upserts with minimal disruption to existing aggregate tables.
|
||||
4. Add reverse-mapping query surfaces for word and kanji occurrences, plus focused API/service exposure only where needed.
|
||||
5. Run focused SQLite verification first, then broader verification only if touched runtime/API files require it.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-14: Design approved in-thread. Chosen shape: `imm_subtitle_lines` plus counted bridge tables `imm_word_line_occurrences` and `imm_kanji_line_occurrences`, retaining repeated tokens within a line via `occurrence_count`.
|
||||
2026-03-14: Implemented additive schema version bump to 7. `recordSubtitleLine(...)` now queues one normalized subtitle-line write that owns aggregate word/kanji upserts plus counted bridge-row inserts.
|
||||
2026-03-14: Added reverse-mapping query surfaces for exact word triples and single kanji lookups. No stats API/UI consumer was widened in this change.
|
||||
2026-03-14: Verification commands run:
|
||||
- `bun test src/core/services/immersion-tracker-service.test.ts`
|
||||
- `bun test src/core/services/immersion-tracker/storage-session.test.ts`
|
||||
- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
- `bun run typecheck`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/types.ts src/core/services/immersion-tracker/storage.ts src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.ts src/core/services/immersion-tracker/storage-session.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/types.ts src/core/services/immersion-tracker/storage.ts src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.ts src/core/services/immersion-tracker/storage-session.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
- `bun run test:immersion:sqlite:src`
|
||||
2026-03-14: Verification results:
|
||||
- targeted tracker/query tests: passed
|
||||
- verifier lane selection: `core`
|
||||
- verifier result: passed (`typecheck`, `test:fast`)
|
||||
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260314-114630-abO7mb/`
|
||||
- maintained immersion SQLite lane: passed
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Added normalized subtitle-line occurrence tracking to immersion stats with three additive tables: `imm_subtitle_lines`, `imm_word_line_occurrences`, and `imm_kanji_line_occurrences`.
|
||||
|
||||
`recordSubtitleLine(...)` now preserves repeated allowed tokens and repeated kanji within the same subtitle line via `occurrence_count`, while still updating canonical `imm_words` and `imm_kanji` aggregates.
|
||||
|
||||
Added reverse-mapping queries for exact word triples and kanji so callers can fetch anime/video/session/line/timestamp context for each occurrence without duplicating token text storage.
|
||||
|
||||
Verified with targeted tracker/query tests, `bun run typecheck`, verifier-selected `core` coverage, and the maintained `bun run test:immersion:sqlite:src` lane.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
id: TASK-172
|
||||
title: Wire immersion occurrence drilldown into stats API and vocabulary drawer
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-14 12:05'
|
||||
updated_date: '2026-03-14 12:11'
|
||||
labels:
|
||||
- immersion
|
||||
- stats
|
||||
- ui
|
||||
dependencies:
|
||||
- TASK-171
|
||||
references: []
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Expose the new immersion word/kanji occurrence queries through the stats server and add a right-side Vocabulary drawer that shows recent occurrence rows with paging when a word or kanji is clicked.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Stats server exposes word and kanji occurrence endpoints with bounded recent-first paging.
|
||||
- [x] #2 Stats client/types support loading occurrence pages for a selected word or kanji.
|
||||
- [x] #3 Vocabulary tab opens a right drawer for the selected word/kanji, shows recent occurrences, and supports loading more.
|
||||
- [x] #4 Focused regression coverage exists for the server endpoint contract, and the stats UI still typechecks/builds.
|
||||
- [x] #5 Verification covers the cheapest sufficient backend and stats-UI lanes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-14: Design approved in-thread. Chosen UX: click a word chip or kanji glyph to open a right-side drawer with recent-first occurrences, initial cap 50, plus “Load more”.
|
||||
2026-03-14: Implemented `/api/stats/vocabulary/occurrences` and `/api/stats/kanji/occurrences` with `limit` + `offset` paging. The drawer uses direct stats HTTP client calls and keeps existing aggregate vocabulary data flow intact.
|
||||
2026-03-14: Verification commands run:
|
||||
- `bun test src/core/services/__tests__/stats-server.test.ts`
|
||||
- `bun run typecheck`
|
||||
- `cd stats && bun run build`
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
- `cd stats && bunx tsc --noEmit`
|
||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/stats-server.ts src/core/services/__tests__/stats-server.test.ts`
|
||||
2026-03-14: Verification results:
|
||||
- stats server endpoint tests: passed
|
||||
- root typecheck: passed
|
||||
- stats UI production build: passed
|
||||
- docs-site test/build: passed
|
||||
- `cd stats && bunx tsc --noEmit`: passed after removing stale `hasCoverArt` prop usage in the library stats UI
|
||||
- verifier result: passed (`typecheck`, `test:fast`)
|
||||
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260314-120900-J0VvB0/`
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Wired occurrence drilldown into the stats server and Vocabulary tab. Words and kanji now open a right-side drawer that loads recent occurrence rows 50 at a time and supports “Load more”.
|
||||
|
||||
Added bounded recent-first occurrence endpoints to the stats HTTP API, extended the stats client/type surface, and made word chips plus kanji glyphs selectable with active-state styling.
|
||||
|
||||
Updated the immersion-tracking docs to mention vocabulary occurrence drilldown. Verified with focused stats-server tests, root typecheck, stats UI production build, docs-site test/build, the repo verifier core lane, and a direct `stats` package typecheck after removing the stale `MediaHeader` prop mismatch.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: TASK-173
|
||||
title: Remove Avg Frequency metric from Vocabulary tab summary cards
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-15 00:13'
|
||||
updated_date: '2026-03-15 00:15'
|
||||
labels:
|
||||
- stats
|
||||
- ui
|
||||
dependencies: []
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
User requested removing the Avg Frequency card/metric because it is not useful. Remove the UI card and stop computing/storing the summary field in dashboard summary shaping code.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Vocabulary tab no longer renders an "Avg Frequency" stat card.
|
||||
- [x] #2 Vocabulary summary model no longer exposes or computes averageFrequency.
|
||||
- [x] #3 Typecheck/tests covering dashboard summary and vocabulary tab pass.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Removed the Vocabulary tab "Avg Frequency" card and deleted the corresponding `averageFrequency` field from `VocabularySummary` and `buildVocabularySummary`.
|
||||
|
||||
Verification run:
|
||||
- `bun test stats/src/lib/dashboard-data.test.ts`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run build`
|
||||
- `bun run test:env`
|
||||
- `bun run test:smoke:dist`
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
50
backlog/tasks/task-176 - Fix-failing-CI-on-PR-24.md
Normal file
50
backlog/tasks/task-176 - Fix-failing-CI-on-PR-24.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: TASK-176
|
||||
title: Fix failing CI on PR 24
|
||||
status: Done
|
||||
assignee:
|
||||
- '@Codex'
|
||||
created_date: '2026-03-15 22:32'
|
||||
updated_date: '2026-03-15 22:36'
|
||||
labels:
|
||||
- ci
|
||||
- github-actions
|
||||
- pr-24
|
||||
dependencies: []
|
||||
references:
|
||||
- 'PR #24'
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Inspect the failing GitHub Actions check on PR 24, reproduce the actionable failure locally when possible, implement the minimum fix, and push until the required PR checks are green.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 The failing GitHub Actions job on PR 24 is inspected and the concrete failure cause is identified.
|
||||
- [x] #2 A minimal code or workflow fix is implemented for the actionable failure.
|
||||
- [x] #3 Relevant local verification is run and recorded.
|
||||
- [x] #4 The fix is pushed and CI is rechecked until required GitHub Actions checks for the PR are green or a specific external blocker is identified.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Investigated failing GitHub Actions run `23120841305` for PR 24. Concrete failure was `bun run typecheck` in step `Build (TypeScript check)` with `src/core/services/subtitle-cue-parser.ts(154,15): error TS18048: 'normalizedSource' is possibly 'undefined'.`
|
||||
|
||||
Fixed by making the URL/path normalization split result total in `detectSubtitleFormat()`, committed as `e940205` (`fix: satisfy subtitle cue parser typecheck`), and pushed to `origin/feature/renderer-performance`.
|
||||
|
||||
Verification: `bun test src/core/services/subtitle-cue-parser.test.ts` passed; a narrow `bunx tsc --noEmit --strict --target es2022 --module esnext --moduleResolution bundler src/core/services/subtitle-cue-parser.ts` compile also passed.
|
||||
|
||||
Post-push PR state is `mergeStateStatus=CLEAN` / `mergeable=MERGEABLE`; GitHub no longer reports the failed `build-test-audit` check. No new GitHub Actions CI run attached to the new head SHA within the follow-up polling window, but the PR is green from GitHub's current mergeability/check view.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Cleared the failing PR CI by fixing the strict-nullability issue in `detectSubtitleFormat()` that broke the TypeScript check on run `23120841305`.
|
||||
|
||||
Pushed `e940205` to the PR branch and confirmed the PR now reports `mergeStateStatus=CLEAN` with no failing checks.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
6
bun.lock
6
bun.lock
@@ -5,9 +5,11 @@
|
||||
"": {
|
||||
"name": "subminer",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"axios": "^1.13.5",
|
||||
"commander": "^14.0.3",
|
||||
"discord-rpc": "^4.0.1",
|
||||
"hono": "^4.12.7",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"libsql": "^0.5.22",
|
||||
"ws": "^8.19.0",
|
||||
@@ -96,6 +98,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
@@ -396,6 +400,8 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
|
||||
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
4
changes/2026-03-13-scrum-master-handoff-checks.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: workflow
|
||||
|
||||
- Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
||||
4
changes/2026-03-14-aur-release-sync.md
Normal file
4
changes/2026-03-14-aur-release-sync.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: release
|
||||
|
||||
- Automate `subminer-bin` AUR package updates from the tagged release workflow.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: install
|
||||
|
||||
- Added Arch Linux AUR install docs for `subminer-bin` in the README and installation guide.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: config
|
||||
|
||||
- add an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently
|
||||
@@ -1,7 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode, including configurable logical bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, d-pad fallback navigation, and slower smooth popup scrolling.
|
||||
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||
- Fixed stale keyboard-only token highlight cleanup when keyboard-only mode turns off or the Yomitan popup closes.
|
||||
6
changes/stats-command.md
Normal file
6
changes/stats-command.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer stats` to launch the local stats dashboard, force-start the stats server on demand, and open the dashboard in your browser.
|
||||
- Added `subminer stats cleanup` to backfill vocabulary metadata and prune stale or excluded immersion rows on demand.
|
||||
- Added `stats.autoOpenBrowser` so browser launch after `subminer stats` can be enabled or disabled explicitly.
|
||||
7
changes/stats-dashboard.md
Normal file
7
changes/stats-dashboard.md
Normal file
@@ -0,0 +1,7 @@
|
||||
type: added
|
||||
area: immersion
|
||||
|
||||
- Added a local stats dashboard for immersion tracking with Overview, Anime, Trends, Vocabulary, and Sessions views.
|
||||
- Added anime progress, episode completion, Anki card links, and occurrence drill-down across the stats dashboard.
|
||||
- Added richer session timelines with new-word activity, cumulative totals, and pause/seek/card event markers.
|
||||
- Added completed-episodes and completed-anime totals to the Overview tracking snapshot.
|
||||
4
changes/yomitan-external-profile-read-only.md
Normal file
4
changes/yomitan-external-profile-read-only.md
Normal file
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: yomitan
|
||||
|
||||
- Added external-profile mode support that keeps Yomitan dictionaries shared while hardening read-only runtime behavior and first-run setup handling.
|
||||
@@ -385,6 +385,17 @@
|
||||
} // Character dictionary setting.
|
||||
}, // Anilist API credentials and update behavior.
|
||||
|
||||
// ==========================================
|
||||
// Yomitan
|
||||
// Optional external Yomitan profile integration.
|
||||
// Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.
|
||||
// For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.
|
||||
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
||||
// ==========================================
|
||||
"yomitan": {
|
||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||
}, // Optional external Yomitan profile integration.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||
@@ -450,5 +461,17 @@
|
||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||
} // Retention setting.
|
||||
} // Enable/disable immersion tracking.
|
||||
}, // Enable/disable immersion tracking.
|
||||
|
||||
// ==========================================
|
||||
// Stats Dashboard
|
||||
// Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||
// Uses the immersion tracking database for overview, trends, sessions, and vocabulary views.
|
||||
// ==========================================
|
||||
"stats": {
|
||||
"toggleKey": "Backquote", // Key code to toggle the stats overlay.
|
||||
"serverPort": 5175, // Port for the stats HTTP server.
|
||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
||||
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
||||
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
In-repo VitePress documentation source for SubMiner.
|
||||
|
||||
Internal architecture/workflow source of truth lives in `docs/README.md` at the repo root. Keep `docs-site/` user-facing.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Architecture
|
||||
|
||||
This page is a contributor-facing architecture summary. Canonical internal architecture guidance lives in `docs/architecture/README.md` at the repo root.
|
||||
|
||||
SubMiner is split into three cooperating runtimes:
|
||||
|
||||
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## v0.6.2 (2026-03-12)
|
||||
- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode.
|
||||
- Reused external Yomitan dictionaries/settings without writing back to that profile.
|
||||
- Let launcher-managed playback honor external Yomitan config instead of forcing first-run setup.
|
||||
- Seeded `config.jsonc` even when the default config directory already exists.
|
||||
- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed.
|
||||
|
||||
## v0.6.0 (2026-03-12)
|
||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
||||
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
SubMiner can build a Yomitan-compatible character dictionary from AniList metadata so that character names in subtitles are recognized, highlighted, and enrichable with context — portraits, roles, voice actors, and biographical detail — without leaving the overlay.
|
||||
|
||||
The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes clickable for a full profile lookup.
|
||||
The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes available for hover-driven Yomitan profile lookup.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -62,6 +62,10 @@ Character dictionary sync is disabled by default. To turn it on:
|
||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
|
||||
:::
|
||||
|
||||
::: warning
|
||||
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
|
||||
:::
|
||||
|
||||
## Name Generation
|
||||
|
||||
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
|
||||
|
||||
@@ -113,9 +113,11 @@ The configuration file includes several main sections:
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
|
||||
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
|
||||
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
|
||||
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
|
||||
- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback
|
||||
|
||||
## Core Settings
|
||||
@@ -1017,6 +1019,33 @@ AniList CLI commands:
|
||||
- `--anilist-setup`: open AniList setup/auth flow helper window.
|
||||
- `--anilist-retry-queue`: process one ready retry queue item immediately.
|
||||
|
||||
### Yomitan
|
||||
|
||||
SubMiner normally uses its bundled Yomitan profile under the app config directory. If you want to reuse dictionaries and profile settings from another Electron app, point SubMiner at that app's Yomitan Electron profile in read-only mode.
|
||||
|
||||
For GameSentenceMiner on Linux, the default overlay profile path is typically `~/.config/gsm_overlay`.
|
||||
|
||||
```json
|
||||
{
|
||||
"yomitan": {
|
||||
"externalProfilePath": "/home/you/.config/gsm_overlay"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
|
||||
|
||||
External-profile mode behavior:
|
||||
|
||||
- SubMiner uses the external profile's Yomitan extension/session instead of its local copy.
|
||||
- SubMiner reads the external profile's currently active Yomitan profile selection and installed dictionaries.
|
||||
- SubMiner does not open its own Yomitan settings window in this mode.
|
||||
- SubMiner does not import, delete, or update dictionaries/settings in the external profile.
|
||||
- SubMiner character-dictionary features are fully disabled in this mode, including auto-sync, manual generation, and subtitle-side character-dictionary annotations.
|
||||
- First-run setup does not require any internal dictionaries while this mode is configured. If you later launch without `yomitan.externalProfilePath`, setup will require at least one internal Yomitan dictionary unless SubMiner already finds one.
|
||||
|
||||
### Jellyfin
|
||||
|
||||
Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch.
|
||||
@@ -1116,7 +1145,7 @@ Troubleshooting:
|
||||
|
||||
### Immersion Tracking
|
||||
|
||||
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions:
|
||||
Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions. This data also powers the stats dashboard:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1162,7 +1191,36 @@ When `dbPath` is blank or omitted, SubMiner writes telemetry and session summari
|
||||
|
||||
Set `dbPath` only if you want to relocate the database (for backup, syncing, or inspection workflows). The database is created when tracking starts for the first time.
|
||||
|
||||
See [Immersion Tracking Storage](/immersion-tracking) for schema details, query templates, retention/rollup behavior, backend portability notes, and the dedicated SQLite verification command.
|
||||
See [Immersion Tracking Storage](/immersion-tracking) for schema details, query templates, dashboard access, retention/rollup behavior, backend portability notes, and the dedicated SQLite verification command.
|
||||
|
||||
### Stats Dashboard
|
||||
|
||||
Configure the local stats UI served from SubMiner and the in-app stats overlay toggle:
|
||||
|
||||
```json
|
||||
{
|
||||
"stats": {
|
||||
"toggleKey": "Backquote",
|
||||
"serverPort": 5175,
|
||||
"autoStartServer": true,
|
||||
"autoOpenBrowser": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------- | ----------------- | --------------------------------------------------------------------------- |
|
||||
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
|
||||
| `serverPort` | integer | Localhost port for the browser stats UI. Default `5175`. |
|
||||
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
|
||||
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `true`. |
|
||||
|
||||
Usage notes:
|
||||
|
||||
- The browser UI is served at `http://127.0.0.1:<serverPort>`.
|
||||
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
|
||||
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
||||
- The UI includes Overview, Anime, Trends, Vocabulary, and Sessions tabs.
|
||||
|
||||
### YouTube Subtitle Generation
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Building & Testing
|
||||
|
||||
For internal architecture/workflow guidance, use `docs/README.md` at the repo root. This page stays focused on contributor-facing build and test commands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh)
|
||||
@@ -13,6 +15,7 @@ cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
|
||||
bun install
|
||||
(cd stats && bun install --frozen-lockfile)
|
||||
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
|
||||
```
|
||||
|
||||
@@ -200,7 +203,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make install-plugin` | Install mpv Lua plugin and config |
|
||||
| `make deps` | Install JS dependencies (root + texthooker-ui) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
@@ -214,7 +217,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
- To add/change generated config template blocks/comments, update `src/config/definitions/template-sections.ts`.
|
||||
- Keep `src/config/definitions.ts` as the composed public API (`DEFAULT_CONFIG`, registries, template export) that wires domain modules together.
|
||||
- Overlay window/visibility state is owned by `src/core/services/overlay-manager.ts`.
|
||||
- Runtime architecture/module-boundary conventions are documented in [Architecture](/architecture); keep contributor changes aligned with that canonical guide.
|
||||
- Runtime architecture/module-boundary conventions are summarized in [Architecture](/architecture), with canonical internal guidance in `docs/architecture/README.md` at the repo root.
|
||||
- Linux packaged desktop launches pass `--background` using electron-builder `build.linux.executableArgs` in `package.json`.
|
||||
- Prefer direct inline deps objects in `src/main/` modules for simple pass-through wiring.
|
||||
- Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Immersion Tracking
|
||||
|
||||
SubMiner can log your watching and mining activity to a local SQLite database. This is optional and disabled by default.
|
||||
SubMiner can log your watching and mining activity to a local SQLite database, then surface it in the built-in stats dashboard. Tracking is enabled by default and can be turned off if you do not want local analytics.
|
||||
|
||||
When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains daily and monthly rollups. You can query the database directly with any SQLite tool to track your progress over time.
|
||||
When enabled, SubMiner records per-session statistics (watch time, subtitle lines seen, words encountered, cards mined) and maintains daily and monthly rollups. You can view that data in SubMiner's stats UI or query the database directly with any SQLite tool.
|
||||
|
||||
## Enabling
|
||||
|
||||
@@ -18,6 +18,44 @@ When enabled, SubMiner records per-session statistics (watch time, subtitle line
|
||||
- Leave `dbPath` empty to use the default location (`immersion.sqlite` in SubMiner's app-data directory).
|
||||
- Set an explicit path to move the database (useful for backups, cloud syncing, or external tools).
|
||||
|
||||
## Stats Dashboard
|
||||
|
||||
The same immersion data powers the stats dashboard.
|
||||
|
||||
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
|
||||
- Launcher command: run `subminer stats` to start the local stats server on demand and open the dashboard in your browser.
|
||||
- Maintenance command: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand.
|
||||
- Browser page: open `http://127.0.0.1:5175` directly if the local stats server is already running.
|
||||
|
||||
Dashboard tabs:
|
||||
|
||||
- Overview: recent sessions, streak calendar, watch-time history, and a tracking snapshot with completed episodes/anime totals
|
||||
- Anime: cover-art library, per-series progress, episode drill-down, and direct links into mined cards
|
||||
- Trends: watch time, sessions, words seen, and per-anime progress/pattern charts
|
||||
- Sessions: expandable session history with new-word activity, cumulative totals, and pause/seek/card markers
|
||||
- Vocabulary: top repeated words, new-word timeline, kanji breakdown, and click-through occurrence drilldown in a right-side drawer
|
||||
|
||||
Stats server config lives under `stats`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"stats": {
|
||||
"toggleKey": "Backquote",
|
||||
"serverPort": 5175,
|
||||
"autoStartServer": true,
|
||||
"autoOpenBrowser": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
||||
- `serverPort` controls the localhost dashboard URL.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active.
|
||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||
- `subminer stats` fails with an error when `immersionTracking.enabled` is `false`.
|
||||
- `subminer stats cleanup` defaults to vocabulary cleanup, repairs stale `headword`, `reading`, and `part_of_speech` values, attempts best-effort MeCab backfill for legacy rows, and removes rows that still fail vocab filtering.
|
||||
|
||||
## Retention Defaults
|
||||
|
||||
Data is kept for the following durations before automatic cleanup:
|
||||
|
||||
@@ -73,9 +73,9 @@ features:
|
||||
src: /assets/tokenization.svg
|
||||
alt: Tracking chart icon
|
||||
title: Immersion Tracking
|
||||
details: Logs watch time, words encountered, and cards mined to SQLite with daily and monthly rollups for long-term progress tracking.
|
||||
details: Logs watch time, words encountered, and cards mined to SQLite, then surfaces the same data in a local stats dashboard with rollups and session drill-down.
|
||||
link: /immersion-tracking
|
||||
linkText: Tracking details
|
||||
linkText: Stats details
|
||||
- icon:
|
||||
src: /assets/cross-platform.svg
|
||||
alt: Cross-platform icon
|
||||
@@ -102,7 +102,7 @@ const demoAssetVersion = '20260223-2';
|
||||
<div class="workflow-step" style="animation-delay: 60ms">
|
||||
<div class="step-number">02</div>
|
||||
<div class="step-title">Lookup</div>
|
||||
<div class="step-desc">Hover or click a token in the interactive overlay to open Yomitan context.</div>
|
||||
<div class="step-desc">Hover a token in the interactive overlay, then trigger Yomitan lookup to open context.</div>
|
||||
</div>
|
||||
<div class="workflow-connector" aria-hidden="true"></div>
|
||||
<div class="workflow-step" style="animation-delay: 120ms">
|
||||
|
||||
@@ -4,10 +4,10 @@ This guide walks through the sentence mining loop — from watching a video to c
|
||||
|
||||
## Overview
|
||||
|
||||
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You click a word to look it up with Yomitan, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
||||
SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You hover a word, trigger Yomitan lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot.
|
||||
|
||||
```text
|
||||
Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki
|
||||
Watch video → See subtitle → Hover word + trigger lookup → Yomitan popup → Add to Anki
|
||||
↓
|
||||
SubMiner auto-fills:
|
||||
sentence, audio, image, translation
|
||||
@@ -30,9 +30,9 @@ SubMiner uses one overlay window with modal surfaces.
|
||||
|
||||
### Primary Subtitle Layer
|
||||
|
||||
The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||
The visible overlay renders subtitles as tokenized hoverable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports:
|
||||
|
||||
- Word-level click targets for Yomitan lookup
|
||||
- Word-level hover targets for Yomitan lookup
|
||||
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
|
||||
- Optional pause while the Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
|
||||
- Right-click to pause/resume
|
||||
@@ -55,9 +55,10 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
||||
## Looking Up Words
|
||||
|
||||
1. Hover over the subtitle area — the overlay activates pointer events.
|
||||
2. Click any word. SubMiner uses Unicode-aware boundary detection (`Intl.Segmenter`) to select it. On macOS, hovering is enough.
|
||||
3. Yomitan detects the selection and opens its lookup popup.
|
||||
4. From the popup, add the word to Anki.
|
||||
2. Hover the word you want. SubMiner keeps per-token boundaries so Yomitan can target that token cleanly.
|
||||
3. Trigger Yomitan lookup with your configured lookup key/modifier (for example `Shift` if that is how your Yomitan profile is set up).
|
||||
4. Yomitan opens its lookup popup for the hovered token.
|
||||
5. From the popup, add the word to Anki.
|
||||
|
||||
### Controller Workflow
|
||||
|
||||
@@ -83,7 +84,7 @@ There are three ways to create cards, depending on your workflow.
|
||||
|
||||
This is the most common flow. Yomitan creates a card in Anki, and SubMiner enriches it automatically.
|
||||
|
||||
1. Click a word → Yomitan popup appears.
|
||||
1. Hover a word, then trigger Yomitan lookup → Yomitan popup appears.
|
||||
2. Click the Anki icon in Yomitan to add the word.
|
||||
3. SubMiner receives or detects the new card:
|
||||
- **Proxy mode** (`ankiConnect.proxy.enabled: true`): immediate enrich after successful `addNote` / `addNotes`.
|
||||
@@ -194,7 +195,7 @@ See [Subtitle Annotations — N+1](/subtitle-annotations#n1-word-highlighting) f
|
||||
|
||||
## Immersion Tracking
|
||||
|
||||
SubMiner can log your watching and mining activity to a local SQLite database — session times, words seen, cards mined, and daily/monthly rollups.
|
||||
SubMiner can log your watching and mining activity to a local SQLite database and expose it in the built-in stats dashboard — session times, words seen, cards mined, and daily/monthly rollups.
|
||||
|
||||
Enable it in your config:
|
||||
|
||||
@@ -205,6 +206,8 @@ Enable it in your config:
|
||||
}
|
||||
```
|
||||
|
||||
See [Immersion Tracking](/immersion-tracking) for the full schema and retention settings.
|
||||
Open the dashboard in the overlay with `stats.toggleKey` (default: `` ` ``), launch it in a browser with `subminer stats`, or visit `http://127.0.0.1:5175` directly if the local stats server is already running. The dashboard covers overview totals, anime progress, session detail, and vocabulary drill-down from the same local immersion database.
|
||||
|
||||
See [Immersion Tracking](/immersion-tracking) for dashboard details, schema, and retention settings.
|
||||
|
||||
Next: [Anki Integration](/anki-integration) — field mapping, media generation, and card enrichment configuration.
|
||||
|
||||
@@ -385,6 +385,17 @@
|
||||
} // Character dictionary setting.
|
||||
}, // Anilist API credentials and update behavior.
|
||||
|
||||
// ==========================================
|
||||
// Yomitan
|
||||
// Optional external Yomitan profile integration.
|
||||
// Setting yomitan.externalProfilePath switches SubMiner to read-only external-profile mode.
|
||||
// For GameSentenceMiner on Linux, the default overlay profile is usually ~/.config/gsm_overlay.
|
||||
// In external-profile mode SubMiner will not import, delete, or modify Yomitan dictionaries/settings.
|
||||
// ==========================================
|
||||
"yomitan": {
|
||||
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
|
||||
}, // Optional external Yomitan profile integration.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
// Optional Jellyfin integration for auth, browsing, and playback launch.
|
||||
@@ -450,5 +461,17 @@
|
||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||
} // Retention setting.
|
||||
} // Enable/disable immersion tracking.
|
||||
}, // Enable/disable immersion tracking.
|
||||
|
||||
// ==========================================
|
||||
// Stats Dashboard
|
||||
// Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||
// Uses the immersion tracking database for overview, trends, sessions, and vocabulary views.
|
||||
// ==========================================
|
||||
"stats": {
|
||||
"toggleKey": "Backquote", // Key code to toggle the stats overlay.
|
||||
"serverPort": 5175, // Port for the stats HTTP server.
|
||||
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
|
||||
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
|
||||
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||
|
||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||
|
||||
## Controller Shortcuts
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection
|
||||
|
||||
## Character-Name Highlighting
|
||||
|
||||
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. Matching names are highlighted in subtitles and become clickable for full character profiles — portraits, roles, voice actors, and biographical detail.
|
||||
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail.
|
||||
|
||||
**How it works:**
|
||||
|
||||
|
||||
@@ -178,11 +178,12 @@ SubMiner does not load the source tree directly from `vendor/subminer-yomitan`;
|
||||
|
||||
If you installed from the AppImage and see this error, the package may be incomplete. Re-download the AppImage or place the unpacked Yomitan extension manually in `~/.config/SubMiner/yomitan`.
|
||||
|
||||
**Yomitan popup does not appear when clicking words**
|
||||
**Yomitan popup does not appear when hovering words and triggering lookup**
|
||||
|
||||
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
||||
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
|
||||
- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below.
|
||||
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
||||
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
|
||||
|
||||
## MeCab / Tokenization
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
3. The overlay connects and subscribes to subtitle changes
|
||||
4. Subtitles are tokenized with Yomitan's internal parser
|
||||
5. Words are displayed as interactive spans in the overlay
|
||||
6. Hovering or clicking a word triggers Yomitan popup for dictionary lookup
|
||||
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
|
||||
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
|
||||
|
||||
There are two ways to use SubMiner:
|
||||
|
||||
33
docs/README.md
Normal file
33
docs/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
<!-- read_when: starting substantial work in this repo or looking for the internal source of truth -->
|
||||
|
||||
# SubMiner Internal Docs
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need internal architecture, workflow, verification, or release guidance
|
||||
|
||||
`docs/` is the internal system of record for agent and contributor knowledge. Start here, then drill into the smallest doc that fits the task.
|
||||
|
||||
## Start Here
|
||||
|
||||
- [Architecture](./architecture/README.md) - runtime map, domains, layering rules
|
||||
- [Workflow](./workflow/README.md) - planning, execution, verification expectations
|
||||
- [Knowledge Base](./knowledge-base/README.md) - how docs are structured, maintained, and audited
|
||||
- [Release Guide](./RELEASING.md) - tagged release checklist
|
||||
- [Plans](./plans/) - active design and implementation artifacts
|
||||
|
||||
## Fast Paths
|
||||
|
||||
- New feature or refactor: [Workflow](./workflow/README.md), then [Architecture](./architecture/README.md)
|
||||
- Test/build/release work: [Verification](./workflow/verification.md), then [Release Guide](./RELEASING.md)
|
||||
- “What owns this behavior?”: [Domains](./architecture/domains.md)
|
||||
- “Can these modules depend on each other?”: [Layering](./architecture/layering.md)
|
||||
- “What doc should exist for this?”: [Catalog](./knowledge-base/catalog.md)
|
||||
|
||||
## Rules
|
||||
|
||||
- Treat `docs/` as canonical for internal guidance.
|
||||
- Treat `docs-site/` as user-facing/public docs.
|
||||
- Keep `AGENTS.md` short; deep detail belongs here.
|
||||
- Update docs when behavior, architecture, or workflow meaningfully changes.
|
||||
@@ -3,20 +3,30 @@
|
||||
# Releasing
|
||||
|
||||
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
|
||||
2. Bump `package.json` to the release version.
|
||||
3. Build release metadata before tagging:
|
||||
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
|
||||
3. Run `bun run changelog:lint`.
|
||||
4. Bump `package.json` to the release version.
|
||||
5. Build release metadata before tagging:
|
||||
`bun run changelog:build --version <version>`
|
||||
4. Review `CHANGELOG.md`.
|
||||
5. Run release gate locally:
|
||||
6. Review `CHANGELOG.md` and `release/release-notes.md`.
|
||||
7. Run release gate locally:
|
||||
`bun run changelog:check --version <version>`
|
||||
`bun run verify:config-example`
|
||||
`bun run test:fast`
|
||||
`bun run typecheck`
|
||||
6. Commit release prep.
|
||||
7. Tag the commit: `git tag v<version>`.
|
||||
8. Push commit + tag.
|
||||
`bun run test:fast`
|
||||
`bun run test:env`
|
||||
`bun run build`
|
||||
8. If `docs-site/` changed, also run:
|
||||
`bun run docs:test`
|
||||
`bun run docs:build`
|
||||
9. Commit release prep.
|
||||
10. Tag the commit: `git tag v<version>`.
|
||||
11. Push commit + tag.
|
||||
|
||||
Notes:
|
||||
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
|
||||
283
docs/architecture/2026-03-15-renderer-performance-design.md
Normal file
283
docs/architecture/2026-03-15-renderer-performance-design.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# Renderer Performance Optimizations
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Status:** Draft
|
||||
|
||||
## Goal
|
||||
|
||||
Minimize the time between a subtitle line appearing and annotations being displayed. Three optimizations target different pipeline stages to achieve this.
|
||||
|
||||
## Current Pipeline (Warm State)
|
||||
|
||||
```text
|
||||
MPV subtitle change (0ms)
|
||||
-> IPC to main (5ms)
|
||||
-> Cache check (2ms)
|
||||
-> [CACHE MISS] Yomitan parser (35-180ms)
|
||||
-> Parallel: MeCab enrichment (20-80ms) + Frequency lookup (15-50ms)
|
||||
-> Annotation stage: 4 sequential passes (25-70ms)
|
||||
-> IPC to renderer (10ms)
|
||||
-> DOM render: createElement per token (15-50ms)
|
||||
─────────────────────────────────
|
||||
Total: ~200-320ms (cache miss)
|
||||
Total: ~72ms (cache hit)
|
||||
```
|
||||
|
||||
## Target Pipeline
|
||||
|
||||
```text
|
||||
MPV subtitle change (0ms)
|
||||
-> IPC to main (5ms)
|
||||
-> Cache check (2ms)
|
||||
-> [CACHE HIT via prefetch] (0ms)
|
||||
-> IPC to renderer (10ms)
|
||||
-> DOM render: cloneNode from template (10-30ms)
|
||||
─────────────────────────────────
|
||||
Total: ~30-50ms (prefetch-warmed, normal playback)
|
||||
|
||||
[CACHE MISS, e.g. immediate seek]
|
||||
-> Yomitan parser (35-180ms)
|
||||
-> Parallel: MeCab enrichment + Frequency lookup
|
||||
-> Annotation stage: 1 batched pass (10-25ms)
|
||||
-> IPC to renderer (10ms)
|
||||
-> DOM render: cloneNode from template (10-30ms)
|
||||
─────────────────────────────────
|
||||
Total: ~150-260ms (cache miss, still improved)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optimization 1: Subtitle Prefetching
|
||||
|
||||
### Summary
|
||||
|
||||
A new `SubtitlePrefetchService` parses external subtitle files and tokenizes upcoming lines in the background before they appear on screen. This converts most cache misses into cache hits during normal playback.
|
||||
|
||||
### Scope
|
||||
|
||||
External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out of scope since Japanese subtitles are virtually always external files.
|
||||
|
||||
### Architecture
|
||||
|
||||
#### Subtitle File Parsing
|
||||
|
||||
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text.
|
||||
|
||||
**Parsed cue structure:**
|
||||
```typescript
|
||||
interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // raw subtitle text
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
||||
|
||||
#### Prefetch Service Lifecycle
|
||||
|
||||
1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure.
|
||||
2. **Parse phase:** Parse all cues from the file content. Sort by start time. Store as an ordered array.
|
||||
3. **Priority window:** Determine the current playback position. Identify the next 10 cues as the priority window.
|
||||
4. **Priority tokenization:** Tokenize the priority window cues sequentially, storing results into the `SubtitleProcessingController`'s tokenization cache.
|
||||
5. **Background tokenization:** After the priority window is done, tokenize remaining cues working forward from the current position, then wrapping around to cover earlier cues. The prefetcher stops once it has tokenized all cues or the cache is full (whichever comes first) to avoid wasteful eviction churn. For files with more cues than the cache limit, background tokenization focuses on cues ahead of the current position.
|
||||
6. **Seek handling:** On seek, re-compute the priority window from the new position. A seek is detected by observing MPV's `time-pos` property and checking if the delta from the last observed position exceeds a threshold (e.g., > 3 seconds forward or any backward jump). The current in-flight tokenization finishes naturally, then the new priority window takes over.
|
||||
7. **Teardown:** When the subtitle track changes or playback ends, stop all prefetch work and discard state.
|
||||
|
||||
#### Live Priority
|
||||
|
||||
The prefetcher and live subtitle handler share the Yomitan parser (single-threaded IPC). Live subtitle requests must always take priority. The prefetcher:
|
||||
|
||||
- Checks a `paused` flag before each cue tokenization. The live handler sets `paused = true` on subtitle change and clears it after emission.
|
||||
- Yields between each background cue tokenization (via `setTimeout(0)` or equivalent) so the live handler can set the pause flag between cues.
|
||||
- When paused, the prefetcher waits (polling the flag on a short interval or awaiting a resume signal) before continuing with the next cue.
|
||||
|
||||
#### Cache Integration
|
||||
|
||||
The prefetcher calls the same `tokenizeSubtitle` function used by live processing to produce `SubtitleData` results, then stores them into the existing `SubtitleProcessingController` tokenization cache via a new method:
|
||||
|
||||
```typescript
|
||||
// New methods on SubtitleProcessingController
|
||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||
isCacheFull: () => boolean;
|
||||
```
|
||||
|
||||
`preCacheTokenization` uses the same `setCachedTokenization` logic internally (LRU eviction, Map-based storage). `isCacheFull` returns `true` when the cache has reached its limit, allowing the prefetcher to stop background tokenization and avoid wasteful eviction churn.
|
||||
|
||||
#### Cache Invalidation
|
||||
|
||||
When the user marks a word as known (or any event triggers `invalidateTokenizationCache()`), all cached results are cleared -- including prefetched ones, since they share the same cache. After invalidation, the prefetcher re-computes the priority window from the current playback position and re-tokenizes those cues to restore warm cache state.
|
||||
|
||||
#### Error Handling
|
||||
|
||||
If the subtitle file is malformed or partially parseable, the cue parser uses what it can extract. A file that yields zero cues disables prefetching silently (falls back to live-only processing). Encoding errors from `loadSubtitleSourceText` are caught and logged; prefetching is skipped for that track.
|
||||
|
||||
#### Integration Points
|
||||
|
||||
- **MPV property subscriptions:** Needs `track-list` (to detect external subtitle file path) and `time-pos` (to track playback position for window calculation and seek detection).
|
||||
- **File loading:** Uses existing `loadSubtitleSourceText` dependency.
|
||||
- **Tokenization:** Calls the same `tokenizeSubtitle` function used by live processing.
|
||||
- **Cache:** Writes into `SubtitleProcessingController`'s cache.
|
||||
- **Cache invalidation:** Listens for cache invalidation events to re-prefetch the priority window.
|
||||
|
||||
### Files Affected
|
||||
|
||||
- **New:** `src/core/services/subtitle-prefetch.ts` -- the prefetch service
|
||||
- **New:** `src/core/services/subtitle-cue-parser.ts` -- SRT/VTT/ASS cue parser (text + timing)
|
||||
- **Modified:** `src/core/services/subtitle-processing-controller.ts` -- expose `preCacheTokenization` method
|
||||
- **Modified:** `src/main.ts` -- wire up the prefetch service, listen to track changes
|
||||
|
||||
---
|
||||
|
||||
## Optimization 2: Batched Annotation Pass
|
||||
|
||||
### Summary
|
||||
|
||||
Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFrequencyMarking` -> `applyJlptMarking` -> `markNPlusOneTargets`) into a single iteration over the token array, followed by N+1 marking.
|
||||
|
||||
**Important context:** Frequency rank _values_ (`token.frequencyRank`) are already assigned at the parser level by `applyFrequencyRanks()` in `tokenizer.ts`, before the annotation stage is called. The annotation stage's `applyFrequencyMarking` only performs POS-based _filtering_ -- clearing `frequencyRank` to `undefined` for tokens that should be excluded (particles, noise tokens, etc.) and normalizing valid ranks. This optimization does not change the parser-level frequency rank assignment; it only batches the annotation-level filtering.
|
||||
|
||||
### Current Flow (4 passes, 4 array copies)
|
||||
|
||||
```text
|
||||
tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||
-> applyKnownWordMarking() // .map() -> new array
|
||||
-> applyFrequencyMarking() // .map() -> new array (POS-based filtering only)
|
||||
-> applyJlptMarking() // .map() -> new array
|
||||
-> markNPlusOneTargets() // .map() -> new array
|
||||
```
|
||||
|
||||
### Dependency Analysis
|
||||
|
||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||
- **N+1 marking:** Uses POS exclusion sets to filter candidates. Depends on known word status + MeCab POS.
|
||||
|
||||
Since frequency filtering and JLPT marking both depend on POS data from MeCab enrichment, and MeCab enrichment already happens before the annotation stage, all four can run in a single pass after MeCab completes.
|
||||
|
||||
### New Flow (1 pass + N+1)
|
||||
|
||||
```typescript
|
||||
function annotateTokens(tokens, deps, options): MergedToken[] {
|
||||
const pos1Exclusions = resolvePos1Exclusions(options);
|
||||
const pos2Exclusions = resolvePos2Exclusions(options);
|
||||
|
||||
// Single pass: known word + frequency filtering + JLPT computed together
|
||||
const annotated = tokens.map((token) => {
|
||||
const isKnown = nPlusOneEnabled
|
||||
? token.isKnown || computeIsKnown(token, deps)
|
||||
: false;
|
||||
|
||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||
const frequencyRank = frequencyEnabled
|
||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled
|
||||
? computeJlptLevel(token, deps.getJlptLevel)
|
||||
: undefined;
|
||||
|
||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||
});
|
||||
|
||||
// N+1 must run after known word status is set for all tokens
|
||||
if (nPlusOneEnabled) {
|
||||
return markNPlusOneTargets(annotated, minSentenceWords, pos1Exclusions, pos2Exclusions);
|
||||
}
|
||||
|
||||
return annotated;
|
||||
}
|
||||
```
|
||||
|
||||
### What Changes
|
||||
|
||||
- The individual `applyKnownWordMarking`, `applyFrequencyMarking`, `applyJlptMarking` functions are refactored into per-token computation helpers (pure functions that compute a single field). The frequency helper is named `filterFrequencyRank` to clarify it performs POS-based exclusion, not rank computation.
|
||||
- The `annotateTokens` orchestrator runs one `.map()` call that invokes all three helpers per token.
|
||||
- `markNPlusOneTargets` remains a separate pass because it needs the full array with `isKnown` set (it examines sentence-level context).
|
||||
- The parser-level `applyFrequencyRanks()` call in `tokenizer.ts` is unchanged -- it remains a separate step outside the annotation stage.
|
||||
- Net: 4 array copies + 4 iterations become 1 array copy + 1 iteration + N+1 pass.
|
||||
|
||||
### Expected Savings
|
||||
|
||||
~15-45ms saved (3 fewer array allocations + 3 fewer full iterations). Annotation drops from ~25-70ms to ~10-25ms.
|
||||
|
||||
### Files Affected
|
||||
|
||||
- **Modified:** `src/core/services/tokenizer/annotation-stage.ts` -- refactor into batched single-pass
|
||||
|
||||
---
|
||||
|
||||
## Optimization 3: DOM Template Pooling
|
||||
|
||||
### Summary
|
||||
|
||||
Replace `document.createElement('span')` calls in the renderer with `templateSpan.cloneNode(false)` from a pre-created template element.
|
||||
|
||||
### Current Behavior
|
||||
|
||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||
1. Clears DOM with `innerHTML = ''`
|
||||
2. Creates a `DocumentFragment`
|
||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||
4. Sets `className`, `textContent`, `dataset.*` individually
|
||||
5. Appends fragment to root
|
||||
|
||||
### New Behavior
|
||||
|
||||
1. At renderer initialization (`createSubtitleRenderer`), create a single template:
|
||||
```typescript
|
||||
const templateSpan = document.createElement('span');
|
||||
```
|
||||
2. In `renderWithTokens`, replace every `document.createElement('span')` with:
|
||||
```typescript
|
||||
const span = templateSpan.cloneNode(false) as HTMLSpanElement;
|
||||
```
|
||||
3. Replace all `innerHTML = ''` calls with `root.replaceChildren()` to avoid the HTML parser invocation on clear. This applies to `renderSubtitle` (primary subtitle root), `renderSecondarySub` (secondary subtitle root), and `renderCharacterLevel` if applicable.
|
||||
4. Everything else stays the same (setting className, textContent, dataset, appending to fragment).
|
||||
|
||||
### Why cloneNode Over Full Node Recycling
|
||||
|
||||
Full recycling (collecting old nodes, clearing attributes, reusing them) requires carefully resetting every `dataset.*` property that might have been set on a previous render. This is error-prone -- a stale `data-frequency-rank` from a previous subtitle appearing on a new token would cause incorrect styling. `cloneNode(false)` on a bare template is nearly as fast and produces a clean node every time.
|
||||
|
||||
### Expected Savings
|
||||
|
||||
`cloneNode(false)` is ~2-3x faster than `createElement` in most browser engines. For 10-15 tokens per subtitle: ~3-8ms saved per render cycle.
|
||||
|
||||
### Files Affected
|
||||
|
||||
- **Modified:** `src/renderer/subtitle-render.ts` -- template creation + cloneNode usage
|
||||
|
||||
---
|
||||
|
||||
## Combined Impact Summary
|
||||
|
||||
| Scenario | Before | After | Improvement |
|
||||
|----------|--------|-------|-------------|
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files
|
||||
- `src/core/services/subtitle-prefetch.ts`
|
||||
- `src/core/services/subtitle-cue-parser.ts`
|
||||
|
||||
### Modified Files
|
||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||
- `src/main.ts` (wire up prefetch service)
|
||||
|
||||
### Test Files
|
||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||
- Updated tests for annotation stage (same behavior, new implementation)
|
||||
- Updated tests for subtitle render (template cloning)
|
||||
37
docs/architecture/README.md
Normal file
37
docs/architecture/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
<!-- read_when: changing runtime wiring, moving code across layers, or trying to find ownership -->
|
||||
|
||||
# Architecture Map
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: runtime ownership, composition boundaries, or layering questions
|
||||
|
||||
SubMiner runs as three cooperating runtimes:
|
||||
|
||||
- Electron desktop app in `src/`
|
||||
- Launcher CLI in `launcher/`
|
||||
- mpv Lua plugin in `plugin/subminer/`
|
||||
|
||||
The desktop app keeps `src/main.ts` as composition root and pushes behavior into small runtime/domain modules.
|
||||
|
||||
## Read Next
|
||||
|
||||
- [Domains](./domains.md) - who owns what
|
||||
- [Layering](./layering.md) - how modules should depend on each other
|
||||
- Public contributor summary: [`docs-site/architecture.md`](../../docs-site/architecture.md)
|
||||
|
||||
## Current Shape
|
||||
|
||||
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
|
||||
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/main/runtime/composers/` owns larger domain compositions.
|
||||
|
||||
## Architecture Intent
|
||||
|
||||
- Small units, explicit boundaries
|
||||
- Composition over monoliths
|
||||
- Pure helpers where possible
|
||||
- Stable user behavior while internals evolve
|
||||
38
docs/architecture/domains.md
Normal file
38
docs/architecture/domains.md
Normal file
@@ -0,0 +1,38 @@
|
||||
<!-- read_when: locating ownership for a runtime, feature, or integration -->
|
||||
|
||||
# Domain Ownership
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: you need to find the owner module for a behavior or test surface
|
||||
|
||||
## Runtime Domains
|
||||
|
||||
- Desktop app runtime: `src/main.ts`, `src/main/`, `src/core/services/`
|
||||
- Overlay renderer: `src/renderer/`
|
||||
- Launcher CLI: `launcher/`
|
||||
- mpv plugin: `plugin/subminer/`
|
||||
|
||||
## Product / Integration Domains
|
||||
|
||||
- Config system: `src/config/`
|
||||
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
|
||||
- MPV runtime and protocol: `src/core/services/mpv*.ts`
|
||||
- Subtitle/token pipeline: `src/core/services/tokenizer*`, `src/subtitle/`, `src/tokenizers/`
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
- AniList tracking: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
- Stats app: `stats/`
|
||||
- Public docs site: `docs-site/`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
- Runtime wiring or dependency setup: start in `src/main/`
|
||||
- Business logic or service behavior: start in `src/core/services/`
|
||||
- UI interaction or overlay DOM behavior: start in `src/renderer/`
|
||||
- Command parsing or mpv launch flow: start in `launcher/`
|
||||
- User-facing docs: `docs-site/`
|
||||
- Internal process/docs: `docs/`
|
||||
33
docs/architecture/layering.md
Normal file
33
docs/architecture/layering.md
Normal file
@@ -0,0 +1,33 @@
|
||||
<!-- read_when: adding dependencies, moving files, or reviewing architecture drift -->
|
||||
|
||||
# Layering Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: deciding whether a dependency direction is acceptable
|
||||
|
||||
## Preferred Dependency Flow
|
||||
|
||||
1. `src/main.ts`
|
||||
2. `src/main/` composition and runtime adapters
|
||||
3. `src/core/services/` focused services
|
||||
4. `src/core/utils/` and other pure helpers
|
||||
|
||||
Renderer, launcher, plugin, and stats each keep their own local layering and should not become a grab bag for unrelated cross-runtime behavior.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep `src/main.ts` thin; wire, do not implement.
|
||||
- Prefer injecting dependencies from `src/main/` instead of reaching outward from core services.
|
||||
- Keep side effects explicit and close to composition boundaries.
|
||||
- Put reusable business logic in focused services, not in top-level lifecycle files.
|
||||
- Keep renderer concerns in `src/renderer/`; avoid leaking DOM behavior into main-process code.
|
||||
- Treat `launcher/*.ts` as source of truth for the launcher. Never hand-edit `dist/launcher/subminer`.
|
||||
|
||||
## Smells
|
||||
|
||||
- `main.ts` grows because logic was not extracted
|
||||
- service reaches directly into unrelated runtime state
|
||||
- renderer code depends on main-process internals
|
||||
- docs-site page becomes the only place internal architecture is explained
|
||||
35
docs/knowledge-base/README.md
Normal file
35
docs/knowledge-base/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
<!-- read_when: changing internal docs structure, adding guidance, or debugging doc drift -->
|
||||
|
||||
# Knowledge Base Rules
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: maintaining the internal doc system itself
|
||||
|
||||
This section defines how the internal knowledge base is organized and maintained.
|
||||
|
||||
## Read Next
|
||||
|
||||
- [Core Beliefs](./core-beliefs.md) - agent-first operating principles
|
||||
- [Catalog](./catalog.md) - indexed docs and verification status
|
||||
- [Quality](./quality.md) - current doc and architecture quality grades
|
||||
|
||||
## Policy
|
||||
|
||||
- `AGENTS.md` is an entrypoint only.
|
||||
- `docs/` is the internal system of record.
|
||||
- `docs-site/` is user-facing; do not treat it as canonical internal design or workflow storage.
|
||||
- Internal docs should be short, cross-linked, and specific.
|
||||
- Every core internal doc should include:
|
||||
- `Status`
|
||||
- `Last verified`
|
||||
- `Owner`
|
||||
- `Read when`
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Update the relevant internal doc when behavior or workflow changes.
|
||||
- Add new docs to the [Catalog](./catalog.md).
|
||||
- Record architectural quality drift in [Quality](./quality.md).
|
||||
- Keep stale docs obvious; do not leave ambiguity about whether a page is trustworthy.
|
||||
29
docs/knowledge-base/catalog.md
Normal file
29
docs/knowledge-base/catalog.md
Normal file
@@ -0,0 +1,29 @@
|
||||
<!-- read_when: you need to know what internal docs exist, whether they are current, or what should be updated -->
|
||||
|
||||
# Documentation Catalog
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: finding internal docs or checking verification status
|
||||
|
||||
| Area | Path | Status | Last verified | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| KB home | `docs/README.md` | active | 2026-03-13 | internal entrypoint |
|
||||
| Architecture index | `docs/architecture/README.md` | active | 2026-03-13 | top-level runtime map |
|
||||
| Domain ownership | `docs/architecture/domains.md` | active | 2026-03-13 | runtime and feature ownership |
|
||||
| Layering rules | `docs/architecture/layering.md` | active | 2026-03-13 | dependency direction and smells |
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-03-13 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-03-13 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-03-13 | lightweight vs execution plans |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-03-13 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-03-13 | release checklist |
|
||||
| Active plans | `docs/plans/` | active | 2026-03-13 | task-scoped design and implementation artifacts |
|
||||
|
||||
## Update Rules
|
||||
|
||||
- Add a row when introducing a new core internal doc.
|
||||
- Update `Status` and `Last verified` when a page is materially revised.
|
||||
- If a page is known inaccurate, mark it stale immediately instead of leaving silent drift.
|
||||
25
docs/knowledge-base/core-beliefs.md
Normal file
25
docs/knowledge-base/core-beliefs.md
Normal file
@@ -0,0 +1,25 @@
|
||||
<!-- read_when: deciding how much context to inject, where docs should live, or how agents should navigate the repo -->
|
||||
|
||||
# Core Beliefs
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: making decisions about agent ergonomics, doc structure, or repository guidance
|
||||
|
||||
## Agent-First Principles
|
||||
|
||||
- Progressive disclosure beats giant injected context.
|
||||
- `AGENTS.md` should map the territory, not duplicate it.
|
||||
- Canonical internal guidance belongs in versioned docs near the code.
|
||||
- Plans are first-class while active work is happening.
|
||||
- Mechanical checks beat social convention when the boundary matters.
|
||||
- Small focused docs are easier to trust, update, and verify.
|
||||
- User-facing docs and internal operating docs should not blur together.
|
||||
|
||||
## What This Means Here
|
||||
|
||||
- Start from `AGENTS.md`, then move into `docs/`.
|
||||
- Prefer links to canonical docs over repeating long instructions.
|
||||
- Keep architecture and workflow docs in separate pages so updates stay targeted.
|
||||
- When a page becomes long or multi-purpose, split it.
|
||||
40
docs/knowledge-base/quality.md
Normal file
40
docs/knowledge-base/quality.md
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- read_when: assessing architecture health, doc gaps, or where cleanup effort should go next -->
|
||||
|
||||
# Quality Scorecard
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: triaging internal quality gaps or deciding where follow-up work is needed
|
||||
|
||||
Grades are directional, not ceremonial. The point is to keep gaps visible.
|
||||
|
||||
## Product / Runtime Domains
|
||||
|
||||
| Area | Grade | Notes |
|
||||
| --- | --- | --- |
|
||||
| Desktop runtime composition | B | strong modularization; still easy for `main` wiring drift to reappear |
|
||||
| Launcher CLI | B | focused surface; generated/stale artifact hazards need constant guarding |
|
||||
| mpv plugin | B | modular, but Lua/runtime coupling still specialized |
|
||||
| Overlay renderer | B | improved modularity; interaction complexity remains |
|
||||
| Config system | A- | clear defaults/definitions split and good validation surface |
|
||||
| Immersion / AniList / Jellyfin surfaces | B- | growing product scope; ownership spans multiple services |
|
||||
| Internal docs system | B | new structure in place; needs habitual maintenance |
|
||||
| Public docs site | B | strong user docs; must stay separate from internal KB |
|
||||
|
||||
## Architectural Layers
|
||||
|
||||
| Layer | Grade | Notes |
|
||||
| --- | --- | --- |
|
||||
| `src/main.ts` composition root | B | direction good; still needs vigilance against logic creep |
|
||||
| `src/main/` runtime adapters | B | mostly clear; can accumulate wiring debt |
|
||||
| `src/core/services/` | B+ | good extraction pattern; some domains remain broad |
|
||||
| `src/renderer/` | B | cleaner than before; UI/runtime behavior still dense |
|
||||
| `launcher/` | B | clear command boundaries |
|
||||
| `docs/` internal KB | B | structure exists; enforcement now guards core rules |
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- Some deep architecture detail still lives in `docs-site/architecture.md` and may merit later migration.
|
||||
- Quality grading is manual and should be refreshed when major refactors land.
|
||||
- Active plans can accumulate without lifecycle cleanup if humans do not prune them.
|
||||
@@ -1,105 +0,0 @@
|
||||
# SubMiner Change Verification Skill Design
|
||||
|
||||
**Date:** 2026-03-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create a SubMiner-specific skill that agents can use to verify code changes with automated checks. The skill must support both targeted regression testing during debugging and pre-handoff verification before final response.
|
||||
|
||||
## Skill Contract
|
||||
|
||||
- **Name:** `subminer-change-verification`
|
||||
- **Trigger:** Use when working in the SubMiner repo and you need to verify code changes actually work, especially for launcher, mpv, plugin, overlay, runtime, Electron, or env-sensitive behavior.
|
||||
- **Default posture:** cheap-first; prefer repo-native tests and narrow lanes before broader or GUI-dependent verification.
|
||||
- **Outputs:**
|
||||
- verification summary
|
||||
- exact commands run
|
||||
- artifact paths for logs, captured summaries, and preserved temp state on failures
|
||||
- skipped lanes and blockers
|
||||
- **Non-goals:**
|
||||
- replacing the repo's native tests
|
||||
- launching real GUI apps for every change
|
||||
- default visual regression or pixel-diff workflows
|
||||
|
||||
## Lane Selection
|
||||
|
||||
The skill chooses lanes from the diff or explicit file list.
|
||||
|
||||
- **`docs`**
|
||||
- For `docs-site/`, `docs/`, and similar documentation-only changes.
|
||||
- Prefer `bun run docs:test` and `bun run docs:build`.
|
||||
- **`config`**
|
||||
- For `src/config/`, config example generation/verification paths, and config-template-sensitive changes.
|
||||
- Prefer `bun run test:config`.
|
||||
- **`core`**
|
||||
- For general source-level changes where type safety and the fast maintained lane are the best cheap signal.
|
||||
- Prefer `bun run typecheck` and `bun run test:fast`.
|
||||
- **`launcher-plugin`**
|
||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||
- Prefer `bun run test:launcher:smoke:src` and `bun run test:plugin:src`.
|
||||
- **`runtime-compat`**
|
||||
- For runtime/composition/bundled behavior where dist-sensitive validation matters.
|
||||
- Prefer `bun run build`, `bun run test:runtime:compat`, and `bun run test:smoke:dist`.
|
||||
- **`real-gui`**
|
||||
- Reserved for cases where actual Electron/mpv/window behavior must be validated.
|
||||
- Not part of the default lane set; the classifier marks these changes as candidates so the agent can escalate deliberately.
|
||||
|
||||
## Escalation Rules
|
||||
|
||||
1. Start with the narrowest lane that credibly exercises the changed behavior.
|
||||
2. If a narrow lane fails in a way that suggests broader fallout, expand once.
|
||||
3. If a change touches launcher/mpv/plugin/runtime/overlay/window tracking paths, include the relevant specialized lanes before falling back to broad suites.
|
||||
4. Treat real GUI/mpv verification as opt-in escalation:
|
||||
- use only when cheaper evidence is insufficient
|
||||
- allow for platform/display/permission blockers
|
||||
- report skipped/blocker states explicitly
|
||||
|
||||
## Helper Script Design
|
||||
|
||||
The skill uses two small shell helpers:
|
||||
|
||||
- **`scripts/classify_subminer_diff.sh`**
|
||||
- Accepts explicit paths or discovers local changes from git.
|
||||
- Emits lane suggestions and flags in a simple line-oriented format.
|
||||
- Marks real GUI-sensitive paths as `flag:real-gui-candidate` instead of forcing GUI execution.
|
||||
- **`scripts/verify_subminer_change.sh`**
|
||||
- Creates an artifact directory under `.tmp/skill-verification/<timestamp>/`.
|
||||
- Selects lanes from the classifier unless lanes are supplied explicitly.
|
||||
- Runs repo-native commands in a stable order and captures stdout/stderr per step.
|
||||
- Writes a compact `summary.json` and a human-readable `summary.txt`.
|
||||
- Skips real GUI verification unless explicitly enabled.
|
||||
|
||||
## Artifact Contract
|
||||
|
||||
Each invocation should create:
|
||||
|
||||
- `summary.json`
|
||||
- `summary.txt`
|
||||
- `classification.txt`
|
||||
- `env.txt`
|
||||
- `lanes.txt`
|
||||
- `steps.tsv`
|
||||
- `steps/<step>.stdout.log`
|
||||
- `steps/<step>.stderr.log`
|
||||
|
||||
Failures should preserve the artifact directory and identify the exact failing command and log paths.
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
1. Inspect changed files or requested area.
|
||||
2. Classify the change into verification lanes.
|
||||
3. Run the cheapest sufficient lane set.
|
||||
4. Escalate only if evidence is insufficient.
|
||||
5. Escalate to real GUI/mpv only for actual Electron/mpv/window behavior claims.
|
||||
6. Return a short report with:
|
||||
- pass/fail/skipped per lane
|
||||
- exact commands run
|
||||
- artifact paths
|
||||
- blockers/gaps
|
||||
|
||||
## Initial Implementation Scope
|
||||
|
||||
- Ship the skill entrypoint plus the classifier/verifier helpers.
|
||||
- Make real GUI verification an explicit future hook rather than a default workflow.
|
||||
- Verify the new skill locally with representative classifier output and artifact generation.
|
||||
@@ -1,111 +0,0 @@
|
||||
# SubMiner Scrum Master Skill Design
|
||||
|
||||
**Date:** 2026-03-10
|
||||
**Status:** Approved
|
||||
|
||||
## Goal
|
||||
|
||||
Create a repo-local skill that can take incoming requests, bugs, or issues in the SubMiner repo, decide whether backlog tracking is warranted, create or update backlog work when appropriate, plan the implementation, dispatch one or more subagents, and ensure verification happens before handoff.
|
||||
|
||||
## Skill Contract
|
||||
|
||||
- **Name:** `subminer-scrum-master`
|
||||
- **Location:** `.agents/skills/subminer-scrum-master/`
|
||||
- **Use when:** the user gives a feature request, bug report, issue, refactor, or implementation ask in the SubMiner repo and the agent should own intake, planning, backlog hygiene, dispatch, and completion flow.
|
||||
- **Responsibilities:**
|
||||
- assess whether backlog tracking is warranted
|
||||
- if needed, search/update/create proper backlog structure
|
||||
- write a plan before dispatching coding work
|
||||
- choose sequential vs parallel execution
|
||||
- assign explicit ownership to workers
|
||||
- require verification before final handoff
|
||||
- **Limits:**
|
||||
- not the default code implementer unless delegation would be wasteful
|
||||
- no overlapping parallel write scopes
|
||||
- no skipping planning before dispatch
|
||||
- no skipping verification
|
||||
- must pause for ambiguous, risky, or external side-effect work
|
||||
|
||||
## Backlog Decision Rules
|
||||
|
||||
Backlog use is conditional, not mandatory.
|
||||
|
||||
- **Skip backlog when:**
|
||||
- question only
|
||||
- obvious mechanical edit
|
||||
- tiny isolated change with no real planning
|
||||
- **Use backlog when:**
|
||||
- implementation requires planning
|
||||
- scope/approach needs decisions
|
||||
- multiple phases or subsystems
|
||||
- likely subagent dispatch
|
||||
- work should remain traceable
|
||||
|
||||
When backlog is used:
|
||||
- search first
|
||||
- update existing matching work when appropriate
|
||||
- otherwise create standalone task or parent task
|
||||
- use parent + subtasks for multi-part work
|
||||
- record the implementation plan before coding starts
|
||||
|
||||
## Orchestration Policy
|
||||
|
||||
The skill orchestrates; workers implement.
|
||||
|
||||
- **No dispatch** for trivial/mechanical work
|
||||
- **Single worker** for focused single-scope work
|
||||
- **Parallel workers** only for clearly disjoint scopes
|
||||
- **Sequential flow** for shared files, runtime coupling, or unclear boundaries
|
||||
|
||||
Every worker should receive:
|
||||
- owned files/modules
|
||||
- explicit reminder not to revert unrelated edits
|
||||
- requirement to report changed files, tests run, and blockers
|
||||
|
||||
## Verification Policy
|
||||
|
||||
Every nontrivial code task gets verification.
|
||||
|
||||
- prefer `subminer-change-verification`
|
||||
- use cheap-first lanes
|
||||
- escalate only when needed
|
||||
- accept worker-run verification only if it is clearly relevant and sufficient
|
||||
- run a consolidating final verification pass when the scrum master needs stronger evidence
|
||||
|
||||
## Representative Flows
|
||||
|
||||
### Trivial fix, no backlog
|
||||
|
||||
1. assess request as mechanical or narrowly reversible
|
||||
2. skip backlog
|
||||
3. keep a short internal plan
|
||||
4. implement directly or use one worker if helpful
|
||||
5. run targeted verification
|
||||
6. report concise summary
|
||||
|
||||
### Single-task implementation
|
||||
|
||||
1. search backlog
|
||||
2. create/update one task
|
||||
3. record plan
|
||||
4. dispatch one worker
|
||||
5. integrate result
|
||||
6. run verification
|
||||
7. update task and report outcome
|
||||
|
||||
### Multi-part feature
|
||||
|
||||
1. search backlog
|
||||
2. create/update parent task
|
||||
3. create subtasks for distinct deliverables/phases
|
||||
4. record sequencing in the plan
|
||||
5. dispatch workers only where write scopes do not overlap
|
||||
6. integrate
|
||||
7. run consolidated verification
|
||||
8. update task state and report outcome
|
||||
|
||||
## V1 Scope
|
||||
|
||||
- instruction-heavy `SKILL.md`
|
||||
- no helper scripts unless orchestration becomes too repetitive
|
||||
- strong coordination with existing Backlog workflow and `subminer-change-verification`
|
||||
@@ -1,110 +0,0 @@
|
||||
# Overlay Controller Support Design
|
||||
|
||||
**Date:** 2026-03-11
|
||||
**Backlog:** `TASK-159`
|
||||
|
||||
## Goal
|
||||
|
||||
Add controller support to the visible overlay through the Chrome Gamepad API without replacing the existing keyboard-only workflow. Controller input should only supplement keyboard-only mode, preserve existing behavior, and expose controller selection plus raw-input debugging in overlay-local modals.
|
||||
|
||||
## Scope
|
||||
|
||||
- Poll connected gamepads from the visible overlay renderer.
|
||||
- Default to the first connected controller unless config specifies a preferred controller.
|
||||
- Add logical controller bindings and tuning knobs to config.
|
||||
- Add `Alt+C` controller selection modal.
|
||||
- Add `Alt+Shift+C` controller debug modal.
|
||||
- Map controller actions onto existing keyboard-only/Yomitan behaviors.
|
||||
- Fix stale selected-token highlight cleanup when keyboard-only mode turns off or popup closes.
|
||||
|
||||
Out of scope for this pass:
|
||||
|
||||
- Raw arbitrary axis/button index remapping in config.
|
||||
- Controller support outside the visible overlay renderer.
|
||||
- Haptics or vibration.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a renderer-local controller runtime. The overlay already owns keyboard-only token selection, Yomitan popup integration, and modal UX, and the Gamepad API is browser-native. A renderer module can poll `navigator.getGamepads()` on animation frames, normalize sticks/buttons into logical actions, and call the same helpers used by keyboard-only mode.
|
||||
|
||||
Avoid synthetic keyboard events as the primary implementation. Analog sticks need deadzones, continuous smooth scrolling, and per-action repeat behavior that do not fit cleanly into key event emulation. Direct logical actions keep tests clear and make the debug modal show the exact values the runtime uses.
|
||||
|
||||
## Behavior
|
||||
|
||||
Controller actions are active only while keyboard-only mode is enabled, except the controller action that toggles keyboard-only mode can always fire so the user can enter the mode from the controller.
|
||||
|
||||
Default logical mappings:
|
||||
|
||||
- left stick vertical: smooth Yomitan popup/window scroll when popup is open
|
||||
- left stick horizontal: move token selection left/right
|
||||
- right stick vertical: smooth Yomitan popup/window scroll
|
||||
- right stick horizontal: jump horizontally inside Yomitan popup/window
|
||||
- `A`: toggle lookup
|
||||
- `B`: close lookup
|
||||
- `Y`: toggle keyboard-only mode
|
||||
- `X`: mine card
|
||||
- `L1` / `R1`: previous / next Yomitan audio
|
||||
- `R2`: activate current Yomitan audio button
|
||||
- `L2`: toggle mpv play/pause
|
||||
|
||||
Selection-highlight cleanup:
|
||||
|
||||
- disabling keyboard-only mode clears the selected token class immediately
|
||||
- closing the Yomitan popup also clears the selected token class if keyboard-only mode is no longer active
|
||||
- helper ownership should live in the shared keyboard-only selection sync path so keyboard and controller exits stay consistent
|
||||
|
||||
## Config
|
||||
|
||||
Add a top-level `controller` block in resolved config with:
|
||||
|
||||
- `enabled`
|
||||
- `preferredGamepadId`
|
||||
- `preferredGamepadLabel`
|
||||
- `smoothScroll`
|
||||
- `scrollPixelsPerSecond`
|
||||
- `horizontalJumpPixels`
|
||||
- `stickDeadzone`
|
||||
- `triggerDeadzone`
|
||||
- `repeatDelayMs`
|
||||
- `repeatIntervalMs`
|
||||
- `bindings` logical fields for the named actions/sticks
|
||||
|
||||
Persist the preferred controller by stable browser-exposed `id` when possible, with label stored as a diagnostic/display fallback.
|
||||
|
||||
## UI
|
||||
|
||||
Controller selection modal:
|
||||
|
||||
- overlay-hosted modal in the visible renderer
|
||||
- lists currently connected controllers
|
||||
- highlights current active choice
|
||||
- selecting one persists config and makes it the active controller immediately if connected
|
||||
|
||||
Controller debug modal:
|
||||
|
||||
- overlay-hosted modal
|
||||
- shows selected controller and all connected controllers
|
||||
- live raw axis array values
|
||||
- live raw button values, pressed flags, and touched flags if available
|
||||
|
||||
## Testing
|
||||
|
||||
Test first:
|
||||
|
||||
- controller gating outside keyboard-only mode
|
||||
- logical mapping to existing helpers
|
||||
- continuous stick scroll and repeat behavior
|
||||
- modal open shortcuts
|
||||
- preferred-controller selection persistence
|
||||
- highlight cleanup on keyboard-only disable and popup close
|
||||
- config defaults/parse/template generation coverage
|
||||
|
||||
## Risks
|
||||
|
||||
- Browser gamepad identity strings can differ across OS/browser/runtime versions.
|
||||
Mitigation: match by exact preferred id first; fall back to first connected controller.
|
||||
- Continuous stick input can spam actions.
|
||||
Mitigation: deadzones plus repeat throttling and frame-time-based smooth scroll.
|
||||
- Popup DOM/audio controls may vary.
|
||||
Mitigation: target stable Yomitan popup/document selectors and cover with focused renderer tests.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
# Overlay Controller Support Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add Chrome Gamepad API controller support to the visible overlay as a supplement to keyboard-only mode, including controller selection/debug modals, config-backed logical bindings, and selected-token highlight cleanup.
|
||||
|
||||
**Architecture:** Keep controller support in the visible overlay renderer. Poll and normalize gamepad state in a dedicated runtime, route logical actions into the existing keyboard-only/Yomitan helpers, and persist preferred-controller config through the existing config pipeline and preload bridge.
|
||||
|
||||
**Tech Stack:** TypeScript, Bun tests, Electron preload IPC, renderer DOM modals, Chrome Gamepad API
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Track work and lock the design
|
||||
|
||||
**Files:**
|
||||
- Create: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
- Create: `docs/plans/2026-03-11-overlay-controller-support.md`
|
||||
|
||||
**Step 1: Record the approved scope**
|
||||
|
||||
Capture controller-only-in-keyboard-mode behavior, the modal shortcuts, config scope, and the stale selection-highlight cleanup requirement.
|
||||
|
||||
**Step 2: Verify the written scope matches the approved design**
|
||||
|
||||
Run: `sed -n '1,220p' backlog/tasks/task-159\\ -\\ Add-overlay-controller-support-for-keyboard-only-mode.md && sed -n '1,240p' docs/plans/2026-03-11-overlay-controller-support-design.md`
|
||||
|
||||
Expected: task and design doc both mention controller selection/debug modals and highlight cleanup.
|
||||
|
||||
### Task 2: Add failing config tests and defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/config/config.test.ts`
|
||||
- Modify: `src/config/definitions/defaults-core.ts`
|
||||
- Modify: `src/config/definitions/options-core.ts`
|
||||
- Modify: `src/config/definitions/template-sections.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `config.example.jsonc`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage asserting a new `controller` config block resolves with the expected defaults and accepts logical-field overrides.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: FAIL because `controller` config is not defined yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add the controller config types/defaults/registry/template wiring and regenerate the example config if needed.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/config/config.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Add failing keyboard-selection cleanup tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/handlers/keyboard.test.ts`
|
||||
- Modify: `src/renderer/handlers/keyboard.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- turning keyboard-only mode off clears `.keyboard-selected`
|
||||
- closing the popup clears stale selection highlight when keyboard-only mode is off
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: FAIL because selection cleanup is incomplete today.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Centralize selection clearing in the keyboard-only sync helpers and popup-close flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: Add failing controller runtime tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- Create: `src/renderer/handlers/gamepad-controller.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Cover:
|
||||
|
||||
- first connected controller is selected by default
|
||||
- preferred controller wins when connected
|
||||
- controller actions are ignored unless keyboard-only mode is enabled, except keyboard-only toggle
|
||||
- stick/button mappings invoke the expected logical helpers
|
||||
- smooth scroll and repeat throttling behavior
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: FAIL because controller runtime does not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add a renderer-local polling runtime with deadzone handling, action edge detection, repeat timing, and helper callbacks into the keyboard/Yomitan flow.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 5: Add failing controller modal tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/index.html`
|
||||
- Modify: `src/renderer/style.css`
|
||||
- Create: `src/renderer/modals/controller-select.ts`
|
||||
- Create: `src/renderer/modals/controller-select.test.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.ts`
|
||||
- Create: `src/renderer/modals/controller-debug.test.ts`
|
||||
- Modify: `src/renderer/renderer.ts`
|
||||
- Modify: `src/renderer/context.ts`
|
||||
- Modify: `src/renderer/state.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `Alt+C` opens controller selection modal
|
||||
- `Alt+Shift+C` opens controller debug modal
|
||||
- selection modal renders connected controllers and persists the chosen device
|
||||
- debug modal shows live axes/buttons state
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: FAIL because modals and shortcuts do not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add modal DOM, renderer modules, modal state wiring, and controller runtime integration.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 6: Persist controller preference through preload/main wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/preload.ts`
|
||||
- Modify: `src/types.ts`
|
||||
- Modify: `src/shared/ipc/contracts.ts`
|
||||
- Modify: `src/core/services/ipc.ts`
|
||||
- Modify: `src/main.ts`
|
||||
- Modify: related main/runtime tests as needed
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add coverage for reading current controller config and saving preferred-controller changes from the renderer.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: FAIL because no controller preference IPC exists yet.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Expose renderer-safe getters/setters for the controller config fields needed by the selection modal/runtime.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 7: Update docs and config example
|
||||
|
||||
**Files:**
|
||||
- Modify: `config.example.jsonc`
|
||||
- Modify: `README.md`
|
||||
- Modify: relevant docs under `docs-site/` for shortcuts/usage/troubleshooting if touched by current docs structure
|
||||
|
||||
**Step 1: Write the failing doc/config check if needed**
|
||||
|
||||
If config example generation is covered by tests, add/refresh the failing assertion first.
|
||||
|
||||
**Step 2: Implement the docs**
|
||||
|
||||
Document controller behavior, modal shortcuts, config block, and the keyboard-only-only activation rule.
|
||||
|
||||
**Step 3: Run doc/config verification**
|
||||
|
||||
Run: `bun run test:config`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 8: Run the handoff gate and update the backlog task
|
||||
|
||||
**Files:**
|
||||
- Modify: `backlog/tasks/task-159 - Add-overlay-controller-support-for-keyboard-only-mode.md`
|
||||
|
||||
**Step 1: Run targeted verification**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun test src/config/config.test.ts`
|
||||
- `bun test src/renderer/handlers/keyboard.test.ts`
|
||||
- `bun test src/renderer/handlers/gamepad-controller.test.ts`
|
||||
- `bun test src/renderer/modals/controller-select.test.ts`
|
||||
- `bun test src/renderer/modals/controller-debug.test.ts`
|
||||
- `bun test src/core/services/ipc.test.ts`
|
||||
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run broader gate**
|
||||
|
||||
Run:
|
||||
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
|
||||
Expected: PASS, or document exact blockers/failures.
|
||||
|
||||
**Step 3: Update backlog notes**
|
||||
|
||||
Fill in implementation notes, verification commands, and final summary in `TASK-159`.
|
||||
30
docs/workflow/README.md
Normal file
30
docs/workflow/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
<!-- read_when: starting implementation, deciding whether to plan, or checking handoff expectations -->
|
||||
|
||||
# Workflow
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: planning or executing nontrivial work in this repo
|
||||
|
||||
This section is the internal workflow map for contributors and agents.
|
||||
|
||||
## Read Next
|
||||
|
||||
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
||||
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
||||
- [Release Guide](../RELEASING.md) - tagged release workflow
|
||||
|
||||
## Default Flow
|
||||
|
||||
1. Read the smallest relevant docs from `docs/`.
|
||||
2. Decide whether the work needs a written plan.
|
||||
3. Implement in small, reviewable edits.
|
||||
4. Run the cheapest sufficient verification lane.
|
||||
5. Escalate to the full maintained gate before handoff when the change is substantial.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Internal process lives in `docs/`.
|
||||
- Public/product docs live in `docs-site/`.
|
||||
- Generated artifacts are never edited by hand.
|
||||
41
docs/workflow/planning.md
Normal file
41
docs/workflow/planning.md
Normal file
@@ -0,0 +1,41 @@
|
||||
<!-- read_when: deciding whether work needs a plan or writing one -->
|
||||
|
||||
# Planning
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: the task spans multiple files, subsystems, or verification lanes
|
||||
|
||||
## Plan Types
|
||||
|
||||
- Lightweight plan: small change, a few reversible steps, minimal coordination
|
||||
- Execution plan: nontrivial feature/refactor/debugging effort with multiple phases or important decisions
|
||||
|
||||
## Use a Lightweight Plan When
|
||||
|
||||
- one subsystem
|
||||
- obvious change shape
|
||||
- low risk
|
||||
- easy to verify
|
||||
|
||||
## Use an Execution Plan When
|
||||
|
||||
- multiple subsystems or runtimes
|
||||
- architectural tradeoffs matter
|
||||
- staged verification is needed
|
||||
- the work should be resumable by another agent or human
|
||||
|
||||
## Plan Location
|
||||
|
||||
- active design and implementation docs live in `docs/plans/`
|
||||
- keep names date-prefixed and task-specific
|
||||
- remove or archive old plans deliberately; do not leave mystery artifacts
|
||||
|
||||
## Plan Contents
|
||||
|
||||
- problem / goal
|
||||
- non-goals
|
||||
- file ownership or edit scope
|
||||
- verification plan
|
||||
- decisions made during execution
|
||||
41
docs/workflow/verification.md
Normal file
41
docs/workflow/verification.md
Normal file
@@ -0,0 +1,41 @@
|
||||
<!-- read_when: choosing what tests/build steps to run before handoff -->
|
||||
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run test:fast
|
||||
bun run test:env
|
||||
bun run build
|
||||
bun run test:smoke:dist
|
||||
```
|
||||
|
||||
If `docs-site/` changed, also run:
|
||||
|
||||
```bash
|
||||
bun run docs:test
|
||||
bun run docs:build
|
||||
```
|
||||
|
||||
## Cheap-First Lane Selection
|
||||
|
||||
- Docs-only boundary/content changes: `bun run docs:test`, `bun run docs:build`
|
||||
- Internal KB / `AGENTS.md` changes: `bun run test:docs:kb`
|
||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
## Rules
|
||||
|
||||
- Capture exact failing command and error when verification breaks.
|
||||
- Prefer the cheapest sufficient lane first.
|
||||
- Escalate when the change crosses boundaries or touches release-sensitive behavior.
|
||||
- Never hand-edit `dist/launcher/subminer`; validate it through build/test flow instead.
|
||||
@@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
||||
import { runDictionaryCommand } from './dictionary-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||
import { runStatsCommand } from './stats-command.js';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
@@ -128,3 +129,98 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
|
||||
/unexpectedly returned/,
|
||||
);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
context.args.logLevel = 'debug';
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
context.args.statsCleanup = true;
|
||||
context.args.statsCleanupVocab = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-vocab',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command throws when stats response reports an error', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 0,
|
||||
waitForStatsResponse: async () => ({
|
||||
ok: false,
|
||||
error: 'Immersion tracking is disabled in config.',
|
||||
}),
|
||||
removeDir: () => {},
|
||||
});
|
||||
},
|
||||
/Immersion tracking is disabled in config\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('stats command fails if attached app exits before startup response', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 2,
|
||||
waitForStatsResponse: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||
},
|
||||
removeDir: () => {},
|
||||
});
|
||||
},
|
||||
/Stats app exited before startup response \(status 2\)\./,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
@@ -101,6 +102,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
launchSetupApp: () => {
|
||||
const setupArgs = ['--background', '--setup'];
|
||||
if (args.logLevel) {
|
||||
|
||||
108
launcher/commands/stats-command.ts
Normal file
108
launcher/commands/stats-command.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runAppCommandAttached } from '../mpv.js';
|
||||
import { sleep } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type StatsCommandResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StatsCommandDeps = {
|
||||
createTempDir: (prefix: string) => string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
runAppCommandAttached: (
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
label: string,
|
||||
) => Promise<number>;
|
||||
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
|
||||
removeDir: (targetPath: string) => void;
|
||||
};
|
||||
|
||||
const defaultDeps: StatsCommandDeps = {
|
||||
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
||||
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
||||
waitForStatsResponse: async (responsePath) => {
|
||||
const deadline = Date.now() + 8000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse;
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Timed out waiting for stats dashboard startup response.',
|
||||
};
|
||||
},
|
||||
removeDir: (targetPath) => {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
|
||||
export async function runStatsCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: StatsCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath } = context;
|
||||
if (!args.stats || !appPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tempDir = deps.createTempDir('subminer-stats-');
|
||||
const responsePath = deps.joinPath(tempDir, 'response.json');
|
||||
|
||||
try {
|
||||
const forwarded = ['--stats', '--stats-response-path', responsePath];
|
||||
if (args.statsCleanup) {
|
||||
forwarded.push('--stats-cleanup');
|
||||
}
|
||||
if (args.statsCleanupVocab) {
|
||||
forwarded.push('--stats-cleanup-vocab');
|
||||
}
|
||||
if (args.logLevel !== 'info') {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
const attachedExitPromise = deps.runAppCommandAttached(
|
||||
appPath,
|
||||
forwarded,
|
||||
args.logLevel,
|
||||
'stats',
|
||||
);
|
||||
const startupResult = await Promise.race([
|
||||
deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })),
|
||||
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||
]);
|
||||
if (startupResult.kind === 'exit') {
|
||||
if (startupResult.status !== 0) {
|
||||
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
|
||||
}
|
||||
const response = await deps.waitForStatsResponse(responsePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!startupResult.response.ok) {
|
||||
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
const exitStatus = await attachedExitPromise;
|
||||
if (exitStatus !== 0) {
|
||||
throw new Error(`Stats app exited with status ${exitStatus}.`);
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
deps.removeDir(tempDir);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { readExternalYomitanProfilePath } from './config.js';
|
||||
import {
|
||||
getPluginConfigCandidates,
|
||||
parsePluginRuntimeConfigContent,
|
||||
@@ -116,3 +117,36 @@ test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
||||
test('getDefaultSocketPath returns Windows named pipe default', () => {
|
||||
assert.equal(getDefaultSocketPath('win32'), '\\\\.\\pipe\\subminer-socket');
|
||||
});
|
||||
|
||||
test('readExternalYomitanProfilePath detects configured external profile paths', () => {
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: {
|
||||
externalProfilePath: ' ~/.config/gsm_overlay ',
|
||||
},
|
||||
}),
|
||||
'~/.config/gsm_overlay',
|
||||
);
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: {
|
||||
externalProfilePath: ' ',
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
readExternalYomitanProfilePath({
|
||||
yomitan: {
|
||||
externalProfilePath: 123,
|
||||
},
|
||||
} as never),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,21 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
|
||||
export function readExternalYomitanProfilePath(
|
||||
root: Record<string, unknown> | null,
|
||||
): string | null {
|
||||
const yomitan =
|
||||
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
|
||||
? (root.yomitan as Record<string, unknown>)
|
||||
: null;
|
||||
const externalProfilePath = yomitan?.externalProfilePath;
|
||||
if (typeof externalProfilePath !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = externalProfilePath.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
|
||||
const root = readLauncherMainConfigObject();
|
||||
if (!root) return {};
|
||||
@@ -29,6 +44,10 @@ export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
||||
return parseLauncherJellyfinConfig(root);
|
||||
}
|
||||
|
||||
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
return readPluginRuntimeConfigValue(logLevel);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,9 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
@@ -188,6 +191,9 @@ export function applyRootOptionsToArgs(
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.statsTriggered) parsed.stats = true;
|
||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||
if (invocations.dictionaryTarget) {
|
||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||
}
|
||||
@@ -256,6 +262,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.dictionaryLogLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
|
||||
}
|
||||
if (invocations.statsLogLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.statsLogLevel);
|
||||
}
|
||||
|
||||
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||
if (invocations.texthookerLogLevel)
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface CliInvocations {
|
||||
dictionaryTriggered: boolean;
|
||||
dictionaryTarget: string | null;
|
||||
dictionaryLogLevel: string | null;
|
||||
statsTriggered: boolean;
|
||||
statsCleanup: boolean;
|
||||
statsCleanupVocab: boolean;
|
||||
statsLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
texthookerTriggered: boolean;
|
||||
@@ -87,6 +91,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'mpv',
|
||||
'dictionary',
|
||||
'dict',
|
||||
'stats',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
@@ -137,6 +142,10 @@ export function parseCliPrograms(
|
||||
let dictionaryTriggered = false;
|
||||
let dictionaryTarget: string | null = null;
|
||||
let dictionaryLogLevel: string | null = null;
|
||||
let statsTriggered = false;
|
||||
let statsCleanup = false;
|
||||
let statsCleanupVocab = false;
|
||||
let statsLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let doctorTriggered = false;
|
||||
@@ -241,6 +250,21 @@ export function parseCliPrograms(
|
||||
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('stats')
|
||||
.description('Launch the local immersion stats dashboard')
|
||||
.argument('[action]', 'cleanup')
|
||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
statsTriggered = true;
|
||||
if ((action || '').toLowerCase() === 'cleanup') {
|
||||
statsCleanup = true;
|
||||
statsCleanupVocab = options.vocab !== false;
|
||||
}
|
||||
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
@@ -319,6 +343,10 @@ export function parseCliPrograms(
|
||||
dictionaryTriggered,
|
||||
dictionaryTarget,
|
||||
dictionaryLogLevel,
|
||||
statsTriggered,
|
||||
statsCleanup,
|
||||
statsCleanupVocab,
|
||||
statsLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
texthookerTriggered,
|
||||
|
||||
@@ -335,6 +335,55 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
||||
});
|
||||
});
|
||||
|
||||
test('stats command launches attached app flow and waits for response file', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
response_path=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--stats-response-path" ]; then
|
||||
response_path="$arg"
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--stats-response-path=*)
|
||||
response_path="\${arg#--stats-response-path=}"
|
||||
;;
|
||||
--stats-response-path)
|
||||
prev="--stats-response-path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$SUBMINER_TEST_STATS_CAPTURE" ]; then
|
||||
printf '%s\\n' "$@" > "$SUBMINER_TEST_STATS_CAPTURE"
|
||||
fi
|
||||
mkdir -p "$(dirname "$response_path")"
|
||||
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_STATS_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['stats', '--log-level', 'debug'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(fs.readFileSync(capturePath, 'utf8'), /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/);
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -14,6 +14,7 @@ import { runConfigCommand } from './commands/config-command.js';
|
||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
||||
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
||||
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runStatsCommand } from './commands/stats-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
|
||||
@@ -95,6 +96,10 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runStatsCommand(appContext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runJellyfinCommand(appContext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
|
||||
@@ -756,6 +756,37 @@ export function runAppCommandCaptureOutput(
|
||||
};
|
||||
}
|
||||
|
||||
export function runAppCommandAttached(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LogLevel,
|
||||
label: string,
|
||||
): Promise<number> {
|
||||
if (maybeCaptureAppArgs(appArgs)) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`${label}: launching attached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: 'inherit',
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
proc.once('exit', (code) => {
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runAppCommandWithInheritLogged(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
@@ -786,10 +817,24 @@ export function runAppCommandWithInheritLogged(
|
||||
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
||||
const startArgs = ['--start'];
|
||||
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
|
||||
if (maybeCaptureAppArgs(startArgs)) {
|
||||
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
|
||||
}
|
||||
|
||||
export function launchAppCommandDetached(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LogLevel,
|
||||
label: string,
|
||||
): void {
|
||||
if (maybeCaptureAppArgs(appArgs)) {
|
||||
return;
|
||||
}
|
||||
const target = resolveAppSpawnTarget(appPath, startArgs);
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||
);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
|
||||
@@ -58,3 +58,26 @@ test('parseArgs maps dictionary command and log-level override', () => {
|
||||
assert.equal(parsed.dictionaryTarget, process.cwd());
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs maps stats command and log-level override', () => {
|
||||
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
@@ -7,10 +7,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 2,
|
||||
version: 3,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -18,10 +19,11 @@ test('waitForSetupCompletion resolves completed and cancelled states', async ()
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -54,10 +56,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
@@ -66,10 +69,11 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
@@ -93,13 +97,33 @@ test('ensureLauncherSetupReady launches setup app and resumes only after complet
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady bypasses setup gate when external yomitan is configured', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => null,
|
||||
isExternalYomitanConfigured: () => true,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 2,
|
||||
version: 3,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
|
||||
@@ -25,12 +25,16 @@ export async function waitForSetupCompletion(deps: {
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
if (isSetupCompleted(deps.readSetupState())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,9 @@ export interface Args {
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinDiscovery: boolean;
|
||||
dictionary: boolean;
|
||||
stats: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
configPath: boolean;
|
||||
|
||||
12
package.json
12
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "subminer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.2",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -13,7 +13,9 @@
|
||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
||||
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
@@ -28,6 +30,7 @@
|
||||
"docs:build": "bun run --cwd docs-site docs:build",
|
||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||
"docs:test": "bun run --cwd docs-site test",
|
||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||
@@ -54,7 +57,7 @@
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
@@ -81,9 +84,11 @@
|
||||
"author": "",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"axios": "^1.13.5",
|
||||
"commander": "^14.0.3",
|
||||
"discord-rpc": "^4.0.1",
|
||||
"hono": "^4.12.7",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"libsql": "^0.5.22",
|
||||
"ws": "^8.19.0"
|
||||
@@ -147,6 +152,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"stats/dist/**/*",
|
||||
"vendor/texthooker-ui/docs/**/*",
|
||||
"vendor/texthooker-ui/package.json",
|
||||
"package.json",
|
||||
|
||||
36
packaging/aur/subminer-bin/.SRCINFO
Normal file
36
packaging/aur/subminer-bin/.SRCINFO
Normal file
@@ -0,0 +1,36 @@
|
||||
pkgbase = subminer-bin
|
||||
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
|
||||
pkgver = 0.6.2
|
||||
pkgrel = 1
|
||||
url = https://github.com/ksyasuda/SubMiner
|
||||
arch = x86_64
|
||||
license = GPL-3.0-or-later
|
||||
depends = bun
|
||||
depends = fuse2
|
||||
depends = glibc
|
||||
depends = mpv
|
||||
depends = zlib-ng-compat
|
||||
optdepends = ffmpeg: media extraction and screenshot generation
|
||||
optdepends = ffmpegthumbnailer: faster thumbnail previews in the launcher
|
||||
optdepends = fzf: terminal media picker in the subminer wrapper
|
||||
optdepends = rofi: GUI media picker in the subminer wrapper
|
||||
optdepends = chafa: image previews in the fzf picker
|
||||
optdepends = yt-dlp: YouTube playback and subtitle extraction
|
||||
optdepends = mecab: optional Japanese metadata enrichment
|
||||
optdepends = mecab-ipadic: dictionary for MeCab metadata enrichment
|
||||
optdepends = python-guessit: improved AniSkip title and episode inference
|
||||
optdepends = alass-git: preferred subtitle synchronization engine
|
||||
optdepends = python-ffsubsync: fallback subtitle synchronization engine
|
||||
provides = subminer=0.6.2
|
||||
conflicts = subminer
|
||||
noextract = SubMiner-0.6.2.AppImage
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
|
||||
source = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
|
||||
source = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
|
||||
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
|
||||
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
|
||||
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
|
||||
|
||||
pkgname = subminer-bin
|
||||
64
packaging/aur/subminer-bin/PKGBUILD
Normal file
64
packaging/aur/subminer-bin/PKGBUILD
Normal file
@@ -0,0 +1,64 @@
|
||||
# Maintainer: Kyle Yasuda <suda@sudacode.com>
|
||||
|
||||
pkgname=subminer-bin
|
||||
pkgver=0.6.2
|
||||
pkgrel=1
|
||||
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
|
||||
arch=('x86_64')
|
||||
url='https://github.com/ksyasuda/SubMiner'
|
||||
license=('GPL-3.0-or-later')
|
||||
options=('!strip' '!debug')
|
||||
depends=(
|
||||
'bun'
|
||||
'fuse2'
|
||||
'glibc'
|
||||
'mpv'
|
||||
'zlib-ng-compat'
|
||||
)
|
||||
optdepends=(
|
||||
'ffmpeg: media extraction and screenshot generation'
|
||||
'ffmpegthumbnailer: faster thumbnail previews in the launcher'
|
||||
'fzf: terminal media picker in the subminer wrapper'
|
||||
'rofi: GUI media picker in the subminer wrapper'
|
||||
'chafa: image previews in the fzf picker'
|
||||
'yt-dlp: YouTube playback and subtitle extraction'
|
||||
'mecab: optional Japanese metadata enrichment'
|
||||
'mecab-ipadic: dictionary for MeCab metadata enrichment'
|
||||
'python-guessit: improved AniSkip title and episode inference'
|
||||
'alass-git: preferred subtitle synchronization engine'
|
||||
'python-ffsubsync: fallback subtitle synchronization engine'
|
||||
)
|
||||
provides=("subminer=${pkgver}")
|
||||
conflicts=('subminer')
|
||||
source=(
|
||||
"SubMiner-${pkgver}.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/SubMiner-${pkgver}.AppImage"
|
||||
"subminer::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer"
|
||||
"subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
|
||||
)
|
||||
sha256sums=(
|
||||
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
|
||||
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
|
||||
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
|
||||
)
|
||||
noextract=("SubMiner-${pkgver}.AppImage")
|
||||
|
||||
package() {
|
||||
install -dm755 "${pkgdir}/usr/bin"
|
||||
|
||||
install -Dm755 "${srcdir}/SubMiner-${pkgver}.AppImage" \
|
||||
"${pkgdir}/opt/SubMiner/SubMiner.AppImage"
|
||||
install -dm755 "${pkgdir}/opt/SubMiner"
|
||||
ln -s '/opt/SubMiner/SubMiner.AppImage' "${pkgdir}/usr/bin/SubMiner.AppImage"
|
||||
|
||||
install -Dm755 "${srcdir}/subminer" "${pkgdir}/usr/bin/subminer"
|
||||
|
||||
install -Dm644 "${srcdir}/config.example.jsonc" \
|
||||
"${pkgdir}/usr/share/SubMiner/config.example.jsonc"
|
||||
install -Dm644 "${srcdir}/plugin/subminer.conf" \
|
||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
||||
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
||||
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
||||
|
||||
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
||||
}
|
||||
@@ -44,6 +44,9 @@ function M.create(ctx)
|
||||
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||
hover.handle_hover_message(payload_json)
|
||||
end)
|
||||
mp.register_script_message("subminer-stats-toggle", function()
|
||||
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,7 @@ function M.create(ctx)
|
||||
"Open options",
|
||||
"Restart overlay",
|
||||
"Check status",
|
||||
"Stats",
|
||||
}
|
||||
|
||||
local actions = {
|
||||
@@ -53,6 +54,9 @@ function M.create(ctx)
|
||||
function()
|
||||
process.check_status()
|
||||
end,
|
||||
function()
|
||||
mp.commandv("script-message", "subminer-stats-toggle")
|
||||
end,
|
||||
}
|
||||
|
||||
input.select({
|
||||
|
||||
68
scripts/docs-knowledge-base.test.ts
Normal file
68
scripts/docs-knowledge-base.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function read(relativePath: string): string {
|
||||
return readFileSync(join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
const requiredDocs = [
|
||||
'docs/README.md',
|
||||
'docs/architecture/README.md',
|
||||
'docs/architecture/domains.md',
|
||||
'docs/architecture/layering.md',
|
||||
'docs/knowledge-base/README.md',
|
||||
'docs/knowledge-base/core-beliefs.md',
|
||||
'docs/knowledge-base/catalog.md',
|
||||
'docs/knowledge-base/quality.md',
|
||||
'docs/workflow/README.md',
|
||||
'docs/workflow/planning.md',
|
||||
'docs/workflow/verification.md',
|
||||
] as const;
|
||||
|
||||
const metadataFields = ['Status:', 'Last verified:', 'Owner:', 'Read when:'] as const;
|
||||
|
||||
test('required internal knowledge-base docs exist', () => {
|
||||
for (const relativePath of requiredDocs) {
|
||||
assert.equal(existsSync(join(repoRoot, relativePath)), true, `${relativePath} should exist`);
|
||||
}
|
||||
});
|
||||
|
||||
test('core internal docs include metadata fields', () => {
|
||||
for (const relativePath of requiredDocs) {
|
||||
const contents = read(relativePath);
|
||||
for (const field of metadataFields) {
|
||||
assert.match(contents, new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('AGENTS.md is a compact map to internal docs', () => {
|
||||
const agentsContents = read('AGENTS.md');
|
||||
const lineCount = agentsContents.trimEnd().split('\n').length;
|
||||
|
||||
assert.ok(lineCount <= 110, `AGENTS.md should stay compact; got ${lineCount} lines`);
|
||||
assert.match(agentsContents, /\.\/docs\/README\.md/);
|
||||
assert.match(agentsContents, /\.\/docs\/architecture\/README\.md/);
|
||||
assert.match(agentsContents, /\.\/docs\/workflow\/README\.md/);
|
||||
assert.match(agentsContents, /\.\/docs\/workflow\/verification\.md/);
|
||||
assert.match(agentsContents, /\.\/docs\/knowledge-base\/README\.md/);
|
||||
assert.match(agentsContents, /\.\/docs\/RELEASING\.md/);
|
||||
assert.match(agentsContents, /`docs-site\/` is user-facing/);
|
||||
assert.doesNotMatch(agentsContents, /\.\/docs-site\/development\.md/);
|
||||
assert.doesNotMatch(agentsContents, /\.\/docs-site\/architecture\.md/);
|
||||
});
|
||||
|
||||
test('docs-site contributor docs point internal readers to docs/', () => {
|
||||
const developmentContents = read('docs-site/development.md');
|
||||
const architectureContents = read('docs-site/architecture.md');
|
||||
const docsReadmeContents = read('docs-site/README.md');
|
||||
|
||||
assert.match(developmentContents, /docs\/README\.md/);
|
||||
assert.match(developmentContents, /docs\/architecture\/README\.md/);
|
||||
assert.match(architectureContents, /docs\/architecture\/README\.md/);
|
||||
assert.match(docsReadmeContents, /docs\/README\.md/);
|
||||
});
|
||||
131
scripts/subminer-change-verification.test.ts
Normal file
131
scripts/subminer-change-verification.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const classifyScript = path.join(
|
||||
repoRoot,
|
||||
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
|
||||
);
|
||||
const verifyScript = path.join(
|
||||
repoRoot,
|
||||
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
|
||||
);
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runBash(args: string[]) {
|
||||
return spawnSync('bash', args, {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function parseArtifactDir(stdout: string): string {
|
||||
const match = stdout.match(/^artifact_dir=(.+)$/m);
|
||||
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
||||
return match[1] ?? '';
|
||||
}
|
||||
|
||||
function readSummaryJson(artifactDir: string) {
|
||||
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
selectedLanes: string[];
|
||||
blockers?: string[];
|
||||
artifactDir: string;
|
||||
pathSelectionMode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
|
||||
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /^lane:launcher-plugin$/m);
|
||||
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
|
||||
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
|
||||
});
|
||||
|
||||
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--lane',
|
||||
'real-runtime',
|
||||
'launcher/mpv.ts',
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0, result.stdout);
|
||||
assert.match(result.stdout, /^result=blocked$/m);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.equal(summary.status, 'blocked');
|
||||
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
|
||||
assert.ok(summary.sessionId.length > 0);
|
||||
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier fails closed for unknown lanes', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--lane',
|
||||
'not-a-lane',
|
||||
'src/main.ts',
|
||||
]);
|
||||
|
||||
assert.notEqual(result.status, 0, result.stdout);
|
||||
assert.match(result.stdout, /^result=failed$/m);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.equal(summary.status, 'failed');
|
||||
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
|
||||
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier allocates unique session ids and artifact roots by default', () => {
|
||||
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||
|
||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
|
||||
const firstArtifactDir = parseArtifactDir(first.stdout);
|
||||
const secondArtifactDir = parseArtifactDir(second.stdout);
|
||||
|
||||
try {
|
||||
const firstSummary = readSummaryJson(firstArtifactDir);
|
||||
const secondSummary = readSummaryJson(secondArtifactDir);
|
||||
|
||||
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
||||
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
|
||||
assert.equal(firstSummary.pathSelectionMode, 'explicit');
|
||||
assert.equal(secondSummary.pathSelectionMode, 'explicit');
|
||||
} finally {
|
||||
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
||||
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
124
scripts/update-aur-package.sh
Executable file
124
scripts/update-aur-package.sh
Executable file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/update-aur-package.sh --pkg-dir <dir> --version <version> --appimage <path> --wrapper <path> --assets <path>
|
||||
EOF
|
||||
}
|
||||
|
||||
pkg_dir=
|
||||
version=
|
||||
appimage=
|
||||
wrapper=
|
||||
assets=
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--pkg-dir)
|
||||
pkg_dir="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
version="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--appimage)
|
||||
appimage="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--wrapper)
|
||||
wrapper="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--assets)
|
||||
assets="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$assets" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${version#v}"
|
||||
pkgbuild="${pkg_dir}/PKGBUILD"
|
||||
|
||||
if [[ ! -f "$pkgbuild" ]]; then
|
||||
echo "Missing PKGBUILD at $pkgbuild" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for artifact in "$appimage" "$wrapper" "$assets"; do
|
||||
if [[ ! -f "$artifact" ]]; then
|
||||
echo "Missing artifact: $artifact" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
awk \
|
||||
-v version="$version" \
|
||||
-v sum_appimage="${sha256sums[0]}" \
|
||||
-v sum_wrapper="${sha256sums[1]}" \
|
||||
-v sum_assets="${sha256sums[2]}" \
|
||||
'
|
||||
BEGIN {
|
||||
in_sha_block = 0
|
||||
found_pkgver = 0
|
||||
found_sha_block = 0
|
||||
}
|
||||
/^pkgver=/ {
|
||||
print "pkgver=" version
|
||||
found_pkgver = 1
|
||||
next
|
||||
}
|
||||
/^sha256sums=\(/ {
|
||||
print "sha256sums=("
|
||||
print "\047" sum_appimage "\047"
|
||||
print "\047" sum_wrapper "\047"
|
||||
print "\047" sum_assets "\047"
|
||||
in_sha_block = 1
|
||||
next
|
||||
}
|
||||
in_sha_block {
|
||||
if ($0 ~ /^\)/) {
|
||||
print ")"
|
||||
in_sha_block = 0
|
||||
found_sha_block = 1
|
||||
}
|
||||
next
|
||||
}
|
||||
{
|
||||
print
|
||||
}
|
||||
END {
|
||||
if (!found_pkgver) {
|
||||
print "Missing pkgver= line in PKGBUILD" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
if (!found_sha_block) {
|
||||
print "Missing sha256sums block in PKGBUILD" > "/dev/stderr"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
' "$pkgbuild" > "$tmpfile"
|
||||
mv "$tmpfile" "$pkgbuild"
|
||||
|
||||
(
|
||||
cd "$pkg_dir"
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
)
|
||||
@@ -137,6 +137,7 @@ export class AnkiIntegration {
|
||||
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
||||
private runtime: AnkiIntegrationRuntime;
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -150,6 +151,7 @@ export class AnkiIntegration {
|
||||
}) => Promise<KikuFieldGroupingChoice>,
|
||||
knownWordCacheStatePath?: string,
|
||||
aiConfig: AiConfig = {},
|
||||
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||
) {
|
||||
this.config = normalizeAnkiIntegrationConfig(config);
|
||||
this.aiConfig = { ...aiConfig };
|
||||
@@ -160,6 +162,7 @@ export class AnkiIntegration {
|
||||
this.osdCallback = osdCallback || null;
|
||||
this.notificationCallback = notificationCallback || null;
|
||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||
this.pollingRunner = this.createPollingRunner();
|
||||
this.cardCreationService = this.createCardCreationService();
|
||||
@@ -208,6 +211,9 @@ export class AnkiIntegration {
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||
processNewCard: (noteId) => this.processNewCard(noteId),
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
this.recordCardsMinedCallback?.(count, noteIds);
|
||||
},
|
||||
isUpdateInProgress: () => this.updateInProgress,
|
||||
setUpdateInProgress: (value) => {
|
||||
this.updateInProgress = value;
|
||||
@@ -229,6 +235,9 @@ export class AnkiIntegration {
|
||||
return new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
this.recordCardsMinedCallback?.(count, noteIds);
|
||||
},
|
||||
getDeck: () => this.config.deck,
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
@@ -1112,4 +1121,8 @@ export class AnkiIntegration {
|
||||
this.stop();
|
||||
this.mediaGenerator.cleanup();
|
||||
}
|
||||
|
||||
setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void {
|
||||
this.recordCardsMinedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,15 @@ async function waitForCondition(
|
||||
|
||||
test('proxy enqueues addNote result for enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
const recordedCards: number[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
@@ -38,6 +42,7 @@ test('proxy enqueues addNote result for enrichment', async () => {
|
||||
|
||||
await waitForCondition(() => processed.length === 1);
|
||||
assert.deepEqual(processed, [42]);
|
||||
assert.deepEqual(recordedCards, [1]);
|
||||
});
|
||||
|
||||
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
|
||||
@@ -64,12 +69,16 @@ test('proxy enqueues addNote bare numeric response for enrichment', async () =>
|
||||
|
||||
test('proxy de-duplicates addNotes IDs within the same response', async () => {
|
||||
const processed: number[] = [];
|
||||
const recordedCards: number[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
@@ -86,6 +95,7 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => {
|
||||
|
||||
await waitForCondition(() => processed.length === 2);
|
||||
assert.deepEqual(processed, [101, 102]);
|
||||
assert.deepEqual(recordedCards, [2]);
|
||||
});
|
||||
|
||||
test('proxy enqueues note IDs from multi action addNote/addNotes results', async () => {
|
||||
@@ -277,12 +287,16 @@ test('proxy does not fallback-enqueue latest note for multi requests without add
|
||||
|
||||
test('proxy fallback-enqueues latest note for addNote responses without note IDs and escapes deck quotes', async () => {
|
||||
const processed: number[] = [];
|
||||
const recordedCards: number[] = [];
|
||||
const findNotesQueries: string[] = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
},
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
getDeck: () => 'My "Japanese" Deck',
|
||||
findNotes: async (query) => {
|
||||
findNotesQueries.push(query);
|
||||
@@ -305,6 +319,7 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
|
||||
await waitForCondition(() => processed.length === 1);
|
||||
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
|
||||
assert.deepEqual(processed, [501]);
|
||||
assert.deepEqual(recordedCards, [1]);
|
||||
});
|
||||
|
||||
test('proxy detects self-referential loop configuration', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ interface AnkiConnectEnvelope {
|
||||
export interface AnkiConnectProxyServerDeps {
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
findNotes?: (
|
||||
query: string,
|
||||
@@ -332,12 +333,14 @@ export class AnkiConnectProxyServer {
|
||||
|
||||
private enqueueNotes(noteIds: number[]): void {
|
||||
let enqueuedCount = 0;
|
||||
const acceptedIds: number[] = [];
|
||||
for (const noteId of noteIds) {
|
||||
if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) {
|
||||
continue;
|
||||
}
|
||||
this.pendingNoteIds.push(noteId);
|
||||
this.pendingNoteIdSet.add(noteId);
|
||||
acceptedIds.push(noteId);
|
||||
enqueuedCount += 1;
|
||||
}
|
||||
|
||||
@@ -345,6 +348,7 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
35
src/anki-integration/polling.test.ts
Normal file
35
src/anki-integration/polling.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PollingRunner } from './polling';
|
||||
|
||||
test('polling runner records newly added cards after initialization', async () => {
|
||||
const recordedCards: number[] = [];
|
||||
let tracked = new Set<number>();
|
||||
const responses = [[10, 11], [10, 11, 12, 13]];
|
||||
const runner = new PollingRunner({
|
||||
getDeck: () => 'Mining',
|
||||
getPollingRate: () => 250,
|
||||
findNotes: async () => responses.shift() ?? [],
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
recordCardsAdded: (count) => {
|
||||
recordedCards.push(count);
|
||||
},
|
||||
isUpdateInProgress: () => false,
|
||||
setUpdateInProgress: () => undefined,
|
||||
getTrackedNoteIds: () => tracked,
|
||||
setTrackedNoteIds: (noteIds) => {
|
||||
tracked = noteIds;
|
||||
},
|
||||
showStatusNotification: () => undefined,
|
||||
logDebug: () => undefined,
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
|
||||
await runner.pollOnce();
|
||||
await runner.pollOnce();
|
||||
|
||||
assert.deepEqual(recordedCards, [2]);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export interface PollingRunnerDeps {
|
||||
) => Promise<number[]>;
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
isUpdateInProgress: () => boolean;
|
||||
setUpdateInProgress: (value: boolean) => void;
|
||||
getTrackedNoteIds: () => Set<number>;
|
||||
@@ -80,6 +81,7 @@ export class PollingRunner {
|
||||
previousNoteIds.add(noteId);
|
||||
}
|
||||
this.deps.setTrackedNoteIds(previousNoteIds);
|
||||
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
|
||||
|
||||
if (this.deps.shouldAutoUpdateNewCards()) {
|
||||
for (const noteId of newNoteIds) {
|
||||
|
||||
@@ -143,6 +143,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(dictionaryTarget.dictionary, true);
|
||||
assert.equal(dictionaryTarget.dictionaryTarget, '/tmp/example.mkv');
|
||||
|
||||
const stats = parseArgs(['--stats', '--stats-response-path', '/tmp/subminer-stats-response.json']);
|
||||
assert.equal(stats.stats, true);
|
||||
assert.equal(stats.statsResponsePath, '/tmp/subminer-stats-response.json');
|
||||
assert.equal(hasExplicitCommand(stats), true);
|
||||
assert.equal(shouldStartApp(stats), true);
|
||||
|
||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface CliArgs {
|
||||
anilistRetryQueue: boolean;
|
||||
dictionary: boolean;
|
||||
dictionaryTarget?: string;
|
||||
stats: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsResponsePath?: string;
|
||||
jellyfin: boolean;
|
||||
jellyfinLogin: boolean;
|
||||
jellyfinLogout: boolean;
|
||||
@@ -97,6 +101,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
@@ -162,6 +169,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--dictionary-target') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.dictionaryTarget = value;
|
||||
} else if (arg === '--stats') args.stats = true;
|
||||
else if (arg === '--stats-cleanup') args.statsCleanup = true;
|
||||
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
|
||||
else if (arg.startsWith('--stats-response-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.statsResponsePath = value;
|
||||
} else if (arg === '--stats-response-path') {
|
||||
const value = readValue(argv[i + 1]);
|
||||
if (value) args.statsResponsePath = value;
|
||||
} else if (arg === '--jellyfin') args.jellyfin = true;
|
||||
else if (arg === '--jellyfin-login') args.jellyfinLogin = true;
|
||||
else if (arg === '--jellyfin-logout') args.jellyfinLogout = true;
|
||||
@@ -331,6 +347,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.anilistSetup ||
|
||||
args.anilistRetryQueue ||
|
||||
args.dictionary ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
@@ -367,6 +384,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.markAudioCard ||
|
||||
args.openRuntimeOptions ||
|
||||
args.dictionary ||
|
||||
args.stats ||
|
||||
args.jellyfin ||
|
||||
args.jellyfinPlay ||
|
||||
args.texthooker
|
||||
@@ -408,6 +426,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.anilistSetup &&
|
||||
!args.anilistRetryQueue &&
|
||||
!args.dictionary &&
|
||||
!args.stats &&
|
||||
!args.jellyfin &&
|
||||
!args.jellyfinLogin &&
|
||||
!args.jellyfinLogout &&
|
||||
|
||||
@@ -18,6 +18,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--help\s+Show this help/);
|
||||
assert.match(output, /default: 7777/);
|
||||
assert.match(output, /--launch-mpv/);
|
||||
assert.match(output, /--stats\s+Open the stats dashboard in your browser/);
|
||||
assert.match(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
|
||||
@@ -14,6 +14,7 @@ ${B}Session${R}
|
||||
--start Connect to mpv and launch overlay
|
||||
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit
|
||||
--stop Stop the running instance
|
||||
--stats Open the stats dashboard in your browser
|
||||
--texthooker Start texthooker server only ${D}(no overlay)${R}
|
||||
|
||||
${B}Overlay${R}
|
||||
|
||||
@@ -30,6 +30,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
|
||||
assert.equal(config.yomitan.externalProfilePath, '');
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
@@ -1194,14 +1195,32 @@ test('controller positive-number tuning rejects sub-unit values that floor to ze
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.scrollPixelsPerSecond, DEFAULT_CONFIG.controller.scrollPixelsPerSecond);
|
||||
assert.equal(config.controller.horizontalJumpPixels, DEFAULT_CONFIG.controller.horizontalJumpPixels);
|
||||
assert.equal(
|
||||
config.controller.scrollPixelsPerSecond,
|
||||
DEFAULT_CONFIG.controller.scrollPixelsPerSecond,
|
||||
);
|
||||
assert.equal(
|
||||
config.controller.horizontalJumpPixels,
|
||||
DEFAULT_CONFIG.controller.horizontalJumpPixels,
|
||||
);
|
||||
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
|
||||
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatDelayMs'), true);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'), true);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.repeatDelayMs'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('controller button index config rejects fractional values', () => {
|
||||
@@ -1223,12 +1242,18 @@ test('controller button index config rejects fractional values', () => {
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.controller.buttonIndices.select, DEFAULT_CONFIG.controller.buttonIndices.select);
|
||||
assert.equal(
|
||||
config.controller.buttonIndices.select,
|
||||
DEFAULT_CONFIG.controller.buttonIndices.select,
|
||||
);
|
||||
assert.equal(
|
||||
config.controller.buttonIndices.leftStickPress,
|
||||
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
|
||||
);
|
||||
assert.equal(warnings.some((warning) => warning.path === 'controller.buttonIndices.select'), true);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.select'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
||||
true,
|
||||
|
||||
@@ -2,10 +2,12 @@ import { RawConfig, ResolvedConfig } from '../types';
|
||||
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
|
||||
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
|
||||
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
|
||||
import { STATS_DEFAULT_CONFIG } from './definitions/defaults-stats';
|
||||
import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle';
|
||||
import { buildCoreConfigOptionRegistry } from './definitions/options-core';
|
||||
import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations';
|
||||
import { buildStatsConfigOptionRegistry } from './definitions/options-stats';
|
||||
import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle';
|
||||
import { buildRuntimeOptionRegistry } from './definitions/runtime-options';
|
||||
import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections';
|
||||
@@ -32,10 +34,11 @@ const {
|
||||
startupWarmups,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitlePosition,
|
||||
@@ -54,11 +57,13 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
anilist,
|
||||
yomitan,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
youtubeSubgen,
|
||||
immersionTracking,
|
||||
stats,
|
||||
};
|
||||
|
||||
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
|
||||
@@ -70,6 +75,7 @@ export const CONFIG_OPTION_REGISTRY = [
|
||||
...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
|
||||
...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
...buildStatsConfigOptionRegistry(DEFAULT_CONFIG),
|
||||
];
|
||||
|
||||
export { CONFIG_TEMPLATE_SECTIONS };
|
||||
|
||||
@@ -2,7 +2,14 @@ import { ResolvedConfig } from '../../types';
|
||||
|
||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'ai' | 'youtubeSubgen'
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'anilist'
|
||||
| 'yomitan'
|
||||
| 'jellyfin'
|
||||
| 'discordPresence'
|
||||
| 'ai'
|
||||
| 'youtubeSubgen'
|
||||
> = {
|
||||
ankiConnect: {
|
||||
enabled: false,
|
||||
@@ -94,6 +101,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
},
|
||||
},
|
||||
},
|
||||
yomitan: {
|
||||
externalProfilePath: '',
|
||||
},
|
||||
jellyfin: {
|
||||
enabled: false,
|
||||
serverUrl: '',
|
||||
|
||||
10
src/config/definitions/defaults-stats.ts
Normal file
10
src/config/definitions/defaults-stats.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ResolvedConfig } from '../../types.js';
|
||||
|
||||
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
|
||||
stats: {
|
||||
toggleKey: 'Backquote',
|
||||
serverPort: 5175,
|
||||
autoStartServer: true,
|
||||
autoOpenBrowser: true,
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'ankiConnect.enabled',
|
||||
'anilist.characterDictionary.enabled',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
@@ -44,6 +45,7 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
'yomitan',
|
||||
'immersionTracking',
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user